From edbcbb249d5f41f458788f4297ac4798f2f5cc3f Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Fri, 26 Jun 2026 15:41:12 +0300 Subject: [PATCH 001/144] feat(staging): add 4 production-ready benchmarks to dev only (PR F) (#740) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After dev → main reconciliation (PRs A-E), dev was hard-reset to main. This commit re-introduces the 4 production-ready benchmarks that lived on the old dev branch as staging-only content. They will NOT be promoted to main until they pass staging review. Benches: - bridge-revenue.yml (188L, status: live) — bridge revenue tracking - evm-quote-latency.yml (194L, status: live) — EVM aggregator quote latency (Mobula vs Codex vs GeckoTerminal etc.) - solana-dex-quote-latency.yml (192L, status: live) — Solana DEX quote latency (Jupiter vs Mobula vs others) - solana-tx-landing.yml (218L, status: live) — Solana tx landing benchmark Companion editorial content (for SEO long-tail): - answers/which-evm-aggregator-has-the-fastest-quote.yml - answers/which-solana-dex-aggregator-is-the-fastest.yml - alternatives/jupiter.yml Workflow going forward: - main = prod (openchainbench.com), source of truth - dev = staging buffer (auto-deploy to staging Preview URL) - These 4 benches live on dev. When approved, open a dedicated PR cherry-picking them to main. - Future feature work: open a feature branch from dev (or main), PR to dev for staging review, then promote to main. Co-authored-by: Florent Tapponnier --- alternatives/jupiter.yml | 13 ++ ...h-evm-aggregator-has-the-fastest-quote.yml | 42 ++++ ...h-solana-dex-aggregator-is-the-fastest.yml | 42 ++++ benchmarks/bridge-revenue.yml | 188 +++++++++++++++ benchmarks/evm-quote-latency.yml | 194 ++++++++++++++++ benchmarks/solana-dex-quote-latency.yml | 192 +++++++++++++++ benchmarks/solana-tx-landing.yml | 218 ++++++++++++++++++ 7 files changed, 889 insertions(+) create mode 100644 alternatives/jupiter.yml create mode 100644 answers/which-evm-aggregator-has-the-fastest-quote.yml create mode 100644 answers/which-solana-dex-aggregator-is-the-fastest.yml create mode 100644 benchmarks/bridge-revenue.yml create mode 100644 benchmarks/evm-quote-latency.yml create mode 100644 benchmarks/solana-dex-quote-latency.yml create mode 100644 benchmarks/solana-tx-landing.yml diff --git a/alternatives/jupiter.yml b/alternatives/jupiter.yml new file mode 100644 index 00000000..d9822c02 --- /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 Solana DEX quote latency 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/answers/which-evm-aggregator-has-the-fastest-quote.yml b/answers/which-evm-aggregator-has-the-fastest-quote.yml new file mode 100644 index 00000000..b1d85c8b --- /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 00000000..11c45532 --- /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/benchmarks/bridge-revenue.yml b/benchmarks/bridge-revenue.yml new file mode 100644 index 00000000..61c6a80e --- /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: "Bridge revenue tracker 2026: cross-chain protocol implied margin USD per day, take rate bps, swap volume (Relay + comparable bridges)" +seo_description: "Live cross-chain bridge implied protocol revenue. (USD in − USD out − gas − app fees) summed over swaps in 24h / 7d / 30d. Upper bound on what each bridge + solvers keep. Relay tracked today; Across / Socket / deBridge to follow. Methodology pre-registered, harness OSS." +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/evm-quote-latency.yml b/benchmarks/evm-quote-latency.yml new file mode 100644 index 00000000..a8ae5d3f --- /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 in 2026: Mobula, KyberSwap, Bebop, CoW, Enso, LI.FI, OpenOcean live latency benchmark" +seo_description: "Live p50/p90/p99 round trip latency for the major EVM swap quoting APIs measured every minute on a rotating basket of top markets across Ethereum, Base, Arbitrum and BSC, with a 3 region geographic split." +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/solana-dex-quote-latency.yml b/benchmarks/solana-dex-quote-latency.yml new file mode 100644 index 00000000..1938a52a --- /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 in 2026: Jupiter vs Mobula vs OpenOcean vs Raydium" +seo_description: "Live ms latency benchmark for Solana DEX aggregator quote APIs. Jupiter, Mobula, OpenOcean and Raydium probed every 60s from 3 regions, p50/p90/p99 over a 24h window." +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.yml b/benchmarks/solana-tx-landing.yml new file mode 100644 index 00000000..2f905446 --- /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 2026: Jito, Helius Sender, Nozomi, bloXroute, 0slot, Astralane live market share" +seo_description: "Live observational benchmark of Solana transaction landing services. Market share by tx count, attribution via known on-chain tip wallets for Jito, Helius Sender, Nozomi, bloXroute, 0slot, NextBlock, Astralane, SolanaVibeStation. Zero on-chain footprint, methodology-public." +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])) From 80690eb82d4b8ace4708c58616da6b8e44d3e1b3 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Fri, 26 Jun 2026 14:53:45 +0200 Subject: [PATCH 002/144] feat(logos): add binance.svg (was using bnb.png from chain registry) --- public/logos/binance.svg | 1 + src/lib/logo-manifest.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 public/logos/binance.svg diff --git a/public/logos/binance.svg b/public/logos/binance.svg new file mode 100644 index 00000000..ecaa7fb4 --- /dev/null +++ b/public/logos/binance.svg @@ -0,0 +1 @@ +Binance \ No newline at end of file diff --git a/src/lib/logo-manifest.ts b/src/lib/logo-manifest.ts index 68765d06..a62b8078 100644 --- a/src/lib/logo-manifest.ts +++ b/src/lib/logo-manifest.ts @@ -100,7 +100,7 @@ const RAW: Record = { "1rpc": "/logos/1rpc.svg", cloudflare: "/logos/cloudflare.svg", "base-official": "/logos/base.jpeg", - binance: "/logos/bnb.png", + binance: "/logos/binance.svg", lava: "/logos/lava.webp", nodies: "/logos/nodies.png", tenderly: "/logos/tenderly.svg", From fe6f261dd23fb57d9a62ef2326e546a71ececd1b Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Fri, 26 Jun 2026 15:03:44 +0200 Subject: [PATCH 003/144] feat(perp-funding-stability): scale to 15 venues + add 8 logos (bench 043) Adds 8 venues sourced via Mobula's CEFI funding-rate aggregator (perp_venue_funding_24h_bps, freshness via perp_venue_last_refresh_unix): - Bitget, Coinbase, Deribit, Gate, Kraken, KuCoin, Lighter, MEXC Same stddev-over-time semantics on a 24h window, BTC/ETH/SOL split per asset. Cohort jumps from 7 to 15 venues. Logos: 5 PNGs sourced from CoinMarketCap exchange CDN (bitget, deribit, gate, kraken, mexc); 2 SVGs from simpleicons (coinbase, kucoin); 1 SVG from simpleicons for binance (was reusing /logos/bnb.png, the chain logo, not the exchange brand). Bench copy + manifest entries updated. --- benchmarks/perp-funding-stability.yml | 124 ++++++++++++++++++++++++-- public/logos/bitget.png | Bin 0 -> 7547 bytes public/logos/coinbase.svg | 1 + public/logos/deribit.png | Bin 0 -> 15506 bytes public/logos/gate.png | Bin 0 -> 16203 bytes public/logos/kraken.png | Bin 0 -> 5879 bytes public/logos/kucoin.svg | 1 + public/logos/mexc.png | Bin 0 -> 12812 bytes src/lib/logo-manifest.ts | 10 +++ 9 files changed, 129 insertions(+), 7 deletions(-) create mode 100644 public/logos/bitget.png create mode 100644 public/logos/coinbase.svg create mode 100644 public/logos/deribit.png create mode 100644 public/logos/gate.png create mode 100644 public/logos/kraken.png create mode 100644 public/logos/kucoin.svg create mode 100644 public/logos/mexc.png diff --git a/benchmarks/perp-funding-stability.yml b/benchmarks/perp-funding-stability.yml index 709b7c00..10f92e7e 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 7 venues ranked" -seo_description: "Live 24h standard deviation of ETH perpetual futures funding rate in bps across Hyperliquid, Bybit, dYdX v4, Binance, OKX, Paradex and Aster. Lower means more stable funding for carry traders and basis arbitrageurs." +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." 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 @@ -24,10 +24,11 @@ seo_intro: | same number. The bench reads the same normalized series as perp-funding (per-venue hold cost in bps per 24h) and exposes stddev_over_time of that series on a 24 hour window for ETH. - Cohort is the 7 venues currently publishing the normalized - hold-24h gauge with the freshness signal: Hyperliquid (onchain), - Bybit, dYdX v4 (Cosmos appchain), Binance, OKX, Paradex (Starknet - L2), Aster (BNB Chain). Lower is better. + Cohort is 15 venues across two sources: 7 polled directly by the + harness (Hyperliquid onchain, Bybit, dYdX v4 Cosmos appchain, + Binance, OKX, Paradex Starknet L2, Aster BNB Chain) and 8 sourced + through Mobula's CEFI funding aggregator (Bitget, Coinbase, + Deribit, Gate, Kraken, KuCoin, Lighter, MEXC). Lower is better. abstract: | The bench reuses the normalized 24h ETH funding series from @@ -43,7 +44,7 @@ methodology: - "Source. Same upstream as perp-funding: each venue's public funding endpoint polled every 60 seconds, normalized to bps per hour and per 24h hold, sign preserved." - "Metric. stddev_over_time(perp_funding_hold_24h_bps{asset=\"ETH\"}[24h]) per venue. The 7d window smooths intraday noise but reacts to regime shifts within a week." - "Sign handling. The underlying series is signed (positive means longs pay). Stability measures the swing, so the ranking uses the unsigned stddev. Average direction and level are the perp-funding bench." - - "Cohort. 7 venues from the perp-funding bench publishing the normalized hold-24h gauge with the freshness signal: Hyperliquid (onchain), Bybit, dYdX v4 (Cosmos appchain), Binance, OKX, Paradex (Starknet L2), Aster (BNB Chain)." + - "Cohort. 15 venues across two upstream sources. 7 polled directly by the harness on each venue's public funding endpoint (perp_funding_hold_24h_bps): Hyperliquid (onchain), Bybit, dYdX v4 (Cosmos appchain), Binance, OKX, Paradex (Starknet L2), Aster (BNB Chain). 8 sourced via Mobula's CEFI funding-rate aggregator (perp_venue_funding_24h_bps): Bitget, Coinbase, Deribit, Gate, Kraken, KuCoin, Lighter, MEXC. Both pipelines normalize to bps per 24h hold with sign preserved, so the unsigned 24h stddev is directly comparable across the full cohort." - "Success. Same freshness signal as perp-funding: perp_funding_venue_ok_unix advancing within the last 5 minutes. A venue whose scrape stalls is tagged stale on the row." - "Cadence. The upstream funding harness polls every 60 seconds; Prometheus scrapes every 30 seconds. The 7 day stddev refreshes on every scrape." - "Failures. A venue whose upstream funding feed errors keeps its last gauge value; the leaderboard tags the row stale once the success gauge drops to 0." @@ -166,3 +167,112 @@ providers: success: (perp_funding_venue_ok_unix{venue="aster"} > bool (time() - 300)) sample_size: count_over_time(perp_funding_hold_24h_bps{venue="aster",asset="ETH"}[24h]) series: stddev_over_time(perp_funding_hold_24h_bps{venue="aster",asset="ETH"}[24h]) + + # ── Mobula-sourced cohort (perp_venue_funding_24h_bps from perp-cohort-stats) ── + # Same metric semantics as perp_funding_hold_24h_bps but normalized + # via Mobula's CEFI funding-rate aggregator instead of direct venue + # polls. Freshness gauge is perp_venue_last_refresh_unix. + + - slug: bitget + name: Bitget + tag: CEX, USDT perpetuals via Mobula aggregator, 8h funding + formula: "stddev_over_time of perp_venue_funding_24h_bps{venue=\"bitget\",asset=\"ETH\"} over a 24h window. Source: Mobula CEFI funding-rate aggregator." + queries: + p50: stddev_over_time(perp_venue_funding_24h_bps{venue="bitget",asset="ETH"}[24h]) + p90: stddev_over_time(perp_venue_funding_24h_bps{venue="bitget",asset="BTC"}[24h]) + p99: stddev_over_time(perp_venue_funding_24h_bps{venue="bitget",asset="SOL"}[24h]) + mean: stddev_over_time(perp_venue_funding_24h_bps{venue="bitget",asset="ETH"}[24h]) + success: (perp_venue_last_refresh_unix{venue="bitget"} > bool (time() - 300)) + sample_size: count_over_time(perp_venue_funding_24h_bps{venue="bitget",asset="ETH"}[24h]) + series: stddev_over_time(perp_venue_funding_24h_bps{venue="bitget",asset="ETH"}[24h]) + + - slug: coinbase + name: Coinbase + tag: Regulated US CEX, perpetuals via Mobula aggregator + formula: "stddev_over_time of perp_venue_funding_24h_bps{venue=\"coinbase\",asset=\"ETH\"} over a 24h window. Source: Mobula CEFI funding-rate aggregator." + queries: + p50: stddev_over_time(perp_venue_funding_24h_bps{venue="coinbase",asset="ETH"}[24h]) + p90: stddev_over_time(perp_venue_funding_24h_bps{venue="coinbase",asset="BTC"}[24h]) + p99: stddev_over_time(perp_venue_funding_24h_bps{venue="coinbase",asset="SOL"}[24h]) + mean: stddev_over_time(perp_venue_funding_24h_bps{venue="coinbase",asset="ETH"}[24h]) + success: (perp_venue_last_refresh_unix{venue="coinbase"} > bool (time() - 300)) + sample_size: count_over_time(perp_venue_funding_24h_bps{venue="coinbase",asset="ETH"}[24h]) + series: stddev_over_time(perp_venue_funding_24h_bps{venue="coinbase",asset="ETH"}[24h]) + + - slug: deribit + name: Deribit + tag: Options-focused CEX, ETH/BTC perpetuals via Mobula aggregator + formula: "stddev_over_time of perp_venue_funding_24h_bps{venue=\"deribit\",asset=\"ETH\"} over a 24h window. Source: Mobula CEFI funding-rate aggregator." + queries: + p50: stddev_over_time(perp_venue_funding_24h_bps{venue="deribit",asset="ETH"}[24h]) + p90: stddev_over_time(perp_venue_funding_24h_bps{venue="deribit",asset="BTC"}[24h]) + p99: stddev_over_time(perp_venue_funding_24h_bps{venue="deribit",asset="SOL"}[24h]) + mean: stddev_over_time(perp_venue_funding_24h_bps{venue="deribit",asset="ETH"}[24h]) + success: (perp_venue_last_refresh_unix{venue="deribit"} > bool (time() - 300)) + sample_size: count_over_time(perp_venue_funding_24h_bps{venue="deribit",asset="ETH"}[24h]) + series: stddev_over_time(perp_venue_funding_24h_bps{venue="deribit",asset="ETH"}[24h]) + + - slug: gate + name: Gate + tag: CEX, USDT perpetuals via Mobula aggregator, 8h funding + formula: "stddev_over_time of perp_venue_funding_24h_bps{venue=\"gate\",asset=\"ETH\"} over a 24h window. Source: Mobula CEFI funding-rate aggregator." + queries: + p50: stddev_over_time(perp_venue_funding_24h_bps{venue="gate",asset="ETH"}[24h]) + p90: stddev_over_time(perp_venue_funding_24h_bps{venue="gate",asset="BTC"}[24h]) + p99: stddev_over_time(perp_venue_funding_24h_bps{venue="gate",asset="SOL"}[24h]) + mean: stddev_over_time(perp_venue_funding_24h_bps{venue="gate",asset="ETH"}[24h]) + success: (perp_venue_last_refresh_unix{venue="gate"} > bool (time() - 300)) + sample_size: count_over_time(perp_venue_funding_24h_bps{venue="gate",asset="ETH"}[24h]) + series: stddev_over_time(perp_venue_funding_24h_bps{venue="gate",asset="ETH"}[24h]) + + - slug: kraken + name: Kraken + tag: Regulated CEX, perpetuals via Mobula aggregator + formula: "stddev_over_time of perp_venue_funding_24h_bps{venue=\"kraken\",asset=\"ETH\"} over a 24h window. Source: Mobula CEFI funding-rate aggregator." + queries: + p50: stddev_over_time(perp_venue_funding_24h_bps{venue="kraken",asset="ETH"}[24h]) + p90: stddev_over_time(perp_venue_funding_24h_bps{venue="kraken",asset="BTC"}[24h]) + p99: stddev_over_time(perp_venue_funding_24h_bps{venue="kraken",asset="SOL"}[24h]) + mean: stddev_over_time(perp_venue_funding_24h_bps{venue="kraken",asset="ETH"}[24h]) + success: (perp_venue_last_refresh_unix{venue="kraken"} > bool (time() - 300)) + sample_size: count_over_time(perp_venue_funding_24h_bps{venue="kraken",asset="ETH"}[24h]) + series: stddev_over_time(perp_venue_funding_24h_bps{venue="kraken",asset="ETH"}[24h]) + + - slug: kucoin + name: KuCoin + tag: CEX, USDT perpetuals via Mobula aggregator, 8h funding + formula: "stddev_over_time of perp_venue_funding_24h_bps{venue=\"kucoin\",asset=\"ETH\"} over a 24h window. Source: Mobula CEFI funding-rate aggregator." + queries: + p50: stddev_over_time(perp_venue_funding_24h_bps{venue="kucoin",asset="ETH"}[24h]) + p90: stddev_over_time(perp_venue_funding_24h_bps{venue="kucoin",asset="BTC"}[24h]) + p99: stddev_over_time(perp_venue_funding_24h_bps{venue="kucoin",asset="SOL"}[24h]) + mean: stddev_over_time(perp_venue_funding_24h_bps{venue="kucoin",asset="ETH"}[24h]) + success: (perp_venue_last_refresh_unix{venue="kucoin"} > bool (time() - 300)) + sample_size: count_over_time(perp_venue_funding_24h_bps{venue="kucoin",asset="ETH"}[24h]) + series: stddev_over_time(perp_venue_funding_24h_bps{venue="kucoin",asset="ETH"}[24h]) + + - slug: lighter + name: Lighter + tag: zkSync L2 perps DEX, native funding via Mobula aggregator + formula: "stddev_over_time of perp_venue_funding_24h_bps{venue=\"lighter\",asset=\"ETH\"} over a 24h window. Source: Mobula CEFI funding-rate aggregator." + queries: + p50: stddev_over_time(perp_venue_funding_24h_bps{venue="lighter",asset="ETH"}[24h]) + p90: stddev_over_time(perp_venue_funding_24h_bps{venue="lighter",asset="BTC"}[24h]) + p99: stddev_over_time(perp_venue_funding_24h_bps{venue="lighter",asset="SOL"}[24h]) + mean: stddev_over_time(perp_venue_funding_24h_bps{venue="lighter",asset="ETH"}[24h]) + success: (perp_venue_last_refresh_unix{venue="lighter"} > bool (time() - 300)) + sample_size: count_over_time(perp_venue_funding_24h_bps{venue="lighter",asset="ETH"}[24h]) + series: stddev_over_time(perp_venue_funding_24h_bps{venue="lighter",asset="ETH"}[24h]) + + - slug: mexc + name: MEXC + tag: CEX, USDT perpetuals via Mobula aggregator, 8h funding + formula: "stddev_over_time of perp_venue_funding_24h_bps{venue=\"mexc\",asset=\"ETH\"} over a 24h window. Source: Mobula CEFI funding-rate aggregator." + queries: + p50: stddev_over_time(perp_venue_funding_24h_bps{venue="mexc",asset="ETH"}[24h]) + p90: stddev_over_time(perp_venue_funding_24h_bps{venue="mexc",asset="BTC"}[24h]) + p99: stddev_over_time(perp_venue_funding_24h_bps{venue="mexc",asset="SOL"}[24h]) + mean: stddev_over_time(perp_venue_funding_24h_bps{venue="mexc",asset="ETH"}[24h]) + success: (perp_venue_last_refresh_unix{venue="mexc"} > bool (time() - 300)) + sample_size: count_over_time(perp_venue_funding_24h_bps{venue="mexc",asset="ETH"}[24h]) + series: stddev_over_time(perp_venue_funding_24h_bps{venue="mexc",asset="ETH"}[24h]) diff --git a/public/logos/bitget.png b/public/logos/bitget.png new file mode 100644 index 0000000000000000000000000000000000000000..0290612bfaa2246cf322b167893734ef0f171199 GIT binary patch literal 7547 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 \ No newline at end of file diff --git a/public/logos/deribit.png b/public/logos/deribit.png new file mode 100644 index 0000000000000000000000000000000000000000..f7be01be934f8b7f12a619bfba43fd55ebebc577 GIT binary patch literal 15506 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 literal 0 HcmV?d00001 diff --git a/public/logos/gate.png b/public/logos/gate.png new file mode 100644 index 0000000000000000000000000000000000000000..9e5900f4640f000794b01c201d49d3733c0b55e5 GIT binary patch literal 16203 zcmcJ0Q+Q?3vhIq}v2EM7(;eHk?Q~Xb+w9o3?R0FL9d~%s`|R`G*ZXoG)?80x&KgxU z{=)bxTtQ9}9u@}{006*CONl9c{g3|pK|_8$t2C-}002Q0X)$3H54}rYcbBYX*OHkl z{Y+<$%xGh2vM`PlaA=IV6uMM2&3bW^v@Jk{igWxA6!XvU20h9xHr^P{b1t{FtN1L} zh(wzh=gtV530d6~sfcV6;rp$117KNCSI=uV2z3-V8NuWZ*k9dKBiN9%YwzoiE&A(j z?`_afJf{=cQ+n%dhf(6t>Uc1uP&}EYAU8kT`US7>WpiyAgpK;5eG^Itmgd ziclqrAcW^+f$s#^ixWMM`+r?hFa1|oZp_(pRAcgZ6%9p0Obbhc`FdjH0RAt&{!JJ9 zJDy+qlv#XA%b2u)7U8nsubG2}mlm_&l3>ap$%9mWCx~yix^FwRKb?pZnf!Y5>3?Ms z5G6UsTmJ}4q#0dRO;Y%M=%4W8NSoxq(y+UzHBp^;Ahzg0lV^%DB=L3(e4CYU9G3F% zGRT3?l8A+p4-O2QC?px}(`@+7F|;|!&I@=D#9P%H_N|6W2#x72zk(~C8L{C7Rgqih z&vL`kS?*x{1I*6^9wQyV%|i(g@qJ0U^E&@}_cFhvPS69Vqf3461YcbC6)VHHw_8+5 zR2v0q-7m_62C6wNFYLs{g7&l+N=j`oy_ZR$t}%rC9b8I8jQ^L`K&P$1zzi-G*}+s= z=fi+HQ2#xfww3l7C1Z`DaE>+5>xt#e$FbGtTj`Bl?;^K9{+!*0+dayb+eOq(MJb;b z_QNi5QfToU&T~FZG@b!IeEoyVJeb(B*P7-a6y`+~h!9N!(a*tvj~F4%c*4kAVyh7B zU3-2g7rqzJEk_HqS48;2!yBUV>#hi%PiPuZlACzqB$#EW_F!*?YeJzedoi|=R-n0- zhukVUo=~=(qoy?$VQ70agdRS!$1ddLQK#1 zi(LI-eE$XuyIfIa0|?bE=_J=jWAh@+mY3>+$?ZTZ5J_7DDSwe_c&zHX*mBLq18m6# zhsaF6@ZlYCw(%ZX#8_kf8%Zhi=)@iwAXf6>EJgwtlF~F~DxszDU;TVWB@7r){ zXx-<#`3(Z+pv!m|HydENZhi%wpImD%f-h!I&7N>Pkklb~SmB%yv*A1Vwsk9y6ZYaR z!>*knXsNu1kiVE3>m=&H^eoEbi+l!9Bftaw@^D2%TaJ@o2rHgtj`jsRvYb7~!w#$G z;4g2dK6o<=-bj@{YpK^WxLeS4{sy6FG*s!6iQ+s*G{rVJ4qpK|-$2R|5sMTBAp9JPbpC0?)I91(0BIt{}M*S#y=+V`~&z+7_f zQW)}mqZLTE8CgU;^^d)0?(h0;An8dkqJOrDx}ttH!qojddD0XAaU;$aeyxCyC#h;- zv?xY{p}f8j%#csl2;}d+qsW+4V6x>s-@;qt`Fg7&2{c_S2c}j~PmdI1>RYP?%0%!t{73K&e2mUathz<5YyXI4{g=*zK z!}Zx+T=6AR&Sp*_2wljo6A-Z_FGP*|gE5mOD3@z0VGmodW-Rq}MaKH=qFHVE$-PBM ztF$0S8fS~>K6g5nzt0zlQ@HB)qn~lexy2E$`{ZxQ{8yZSb3g(LpMvoq`=xQ;c>YT! z<webvR zG|zR`l?|)T4_50cA$ty9Kpp2M0AP56k-}seCqubl`&l^cN{Ji0&mAMxSE%5rq)ND6h|oBHAqwwGH7uZC5Su&%K$Q z2Z>)&3m8fyArpnzC(^>0Bt&ZHNgJ!(IOV}L^4x3X2(3lq16ML_5Rr<1G8tIA4Z_5D zT=jzAPl6vYlYdC;sL$jzcR@AbJFlExiu+38T{h40`8VSJ+vX|(ky&A^p7;hdTnqni za47eoky*y;%Z}?;3rEI$fp*{;RET3{3)JGC!}9CV9r$m%f4*np4NTjwnM@Dhex%7T zre?2c-#bXw-13-TnSc+lL$<7kwYl~US z$wji$8ZjONk_3^eup1muC~*7zfA!Z)K$PKSSD;#v-XbQ>(AdEgZ9)+v?fKFzncHWF zOq1%4k}8U4pKm%vO^4UtHe2%zwt7K!f4xuIhMuedVA!*uqdIs*25?x3w_KXCnV>;C!i z5?By$^fFGFh>F;Bj5C>Igu@^Pzt<~&BmN{P@T3C>v3 zls7l_xx;!jj-#jlU7%4GN1?XK5vF82`0v#mfG}x3Hi6JG>4#81*4QN}L>f@$Xtgtm_q7nzHVt7TwD2bB>?@UnbVp-edAji*2J0j zi;1`LACQ8<_<9NJDQL?5C{_cE>?-SL7wRNmsK(H177JEcXHdbbx=lx29ZZd3;59NKIL~X5jXMp95LU;KD==?99(NiJWsHvC z6*Dh>jf5|B{IS9UK|Y^`DWrV5kI#SI&BhXeLLP_JdXPrPR)!8N2`ghLmuKat^p89W zn7D-mOW}IAwq|VW2dvta1kZ`dIuPi-?#;3jfM;R5YS!Oh=qblz1|lZA*~C}2G6Swr zK&;y?q2;EZCY<@)IvROR!2vRj7=z61n*JV=)SV%oSe< zYi%mc4Gc8yl}}&P(*)t+=_!wyi?myEL(IqLHV0Tp-T3Z4J^|L?6=v<;_$|AKHSqHe zbe7hb84?U!*9uj%Kn|6QxTak41I=Fg6Kt#gt>@+%;}gbS(`#d?)A_*K14YHFK-x$X z0-}WXxYpjC23yEuDKNOGsV5C?&zryp^wSad8Ea#-P-0$zPM#K$0;03f;xWo7)*Kdo zjzHz_Yf#DTO1zLMZX(r8A^S7`+qgQ#EdMy`{cQJXh=s;2V1@lIF@|BpM2A49D|Yu- zf?!}KhCk*uzh-wSe#CaFbE{#=H;zqj43~Aj^YY7o#|r`$ zda@|v+>F(9OsIc*xkVI=9cK$Qxnm^nab8^T1!~6(DiUg9DBOn${$UgNTmh9xjVkPl z`pHhY@AXN-Y6*r=D2)%afua!*Ite(Csf;SCpBDLD-R!yW9J#HRr%-%k(3_XT55+%U z`pvCx(Gik&fV6cH@!~0V7n9Hnqol-wg}g2)8$&Xae>y)aiI8G}z7oGWipCL8&tLis zm>X4fTBLekAHQDKE~v z(eHnuNM|2kM6=_W0KWnB3}(ZTFXZW2zwd?wBC{C9Usyqd`c-X~A%EQ$e8g3;6EDzy z4l(>I9CLr+isc`_2V~X0pN-%~o}iYT)y^d{n#6yYt+qe;ayola z>7pKMN==lZu{!0fXSdgoMnU$x3~oGQlAU^;07m9CJVIO!8?rqth!^k(`NOQ`z972Y z%eC{I|BSTXuE4mpm@aa!K5fi4M0T%83O6Vm&_*;Ibil8~MTNTz=-=)lz^!;5^_>O@ ztNOC{?yvz#N7NH!u8`|9C=KFBU5hPB$NT=uL*D!R>bBc9wok(ZIHt=SB?Sv}QN!Jf z(l6~_0fsgb)zzlj2Rx^GGsRpG)nH$xEU)q|U@QaGe;Ddm;OwG2yGpZ+1PzSEqbgrK zsfFG$5T}{n_%}i3awYnxQbW$ztjKa<>G2hRgY7_|#eK|PZykfq3G3g{`~2T!K~}*V z<27?_B4ht#bO44uD=2VL44+OOh`i^W3hx?q92W0zom~+!6DwbCuoY!zFTvW3rSCxN ziP)C@0CWJ$rxhpMkI24yX|nH2(rq=m{|fm@sR&5pS~>JJ+Ki-0!# zX?Gsm{4G$JCgbn+jY3~$v0eiQV#(&O1r{1%X!zz}FnizMna}x9MXyN_j)SJMtBTGT zvNFfhr)>*a{1{`aDCLGmC|>nDns6qm-7_vatWNmmW$z`rkZ|U5Q}&1ct0w?hO;b@m z$k;$4GFwgq9rWsu?LMBxLyAVm77t#&CoVCt;?%?im>hQnmhuaPf7kB`5cQ4X-Wp5b zI?6K2b|M8};6__!-30v8p=Bb#sNQ9YXy|Y?K<=d_2U{$<2I?W7pmoF8NNuV*Lp5W2 z(3#MlsPWf%9yLVvt2^;ea|K|=3D-`5@QJet zk0t&OKOxa$Ivf$-;cEjBuAG_2sq`t%&m~OLQf`(!gtn`Qi3i3i8S9KN6iTmTrw^!A zEjSJ^Dg+@L*-QWuDN|d}pqQ7QY=ty-cE_fmgsTV2gFka)$eu-1x7D<_M=CnquM^R3 zWY(k!Ay9lL2~2Q;wBE3ElZrFoTpXSDpbwxNOE@m4inE6?3=g=H0ShS=z-zAKIPW81 z4KAtY_d;7(3O8~GUcWGD0E{%>5(Rr%K|*U7PZTqj6dYfh-it6Wq_!R^WinY>85k`)8^6yrVc~ZaJ!F;IzZi;*Jo&ESi5#Zz(9Xy6+lp$aFvsc$nSb^O1|ysLq&D$EUanl;v~4Km z@q%UND77BiI#vC!fU#6HzmKJc2I;X{^pvu{y#JyH$eDTW9H_t8;f+2D{av@)1nMQDSD5kD!{Y zxqRqp^-3mqz_;^NkWq;w(@g}1QC8Tbb;RhMW~tc9l>rNv*h3hbvh;4rbk1P@D`c57 zWCsEp5_0;n4Ws{P8H;FD3~#~`VYuLm^_rJ03V)YiwA?A{C~C!hg6iHAw9>h-=|CoOmVAw3hXL{jm__a1eC$g#TYOMQ%~T_)PMs!mxnuXD=!hdxP)4cng{LgbD z_QK!2Qp7aUrp5RPi~Gq#n2X|uXC9lT$x7YgM$Tu+rc3PiJlzlM`zUOX1B$#qNh~jd z$MZI@cFh0PIX)(K9OB0j%vXPI4J<1_8~^Gz+ugs$2Zqq>@mLeJ1zfaspJg+D#XaKr z)<`{QfI+QOEgsMYhZnF4Y)mSK8{I?{2xegA&s1~ zxO=7O8M-bwO{K{dZXnAO$-UNkIT_$f;8 z0|lxnVrdoLqD4T1zN#i}&?1(#ioO6fd&ZA5Pd!3+-ckv=jx!!$B}T&5_y0zh$$H@m zV8J)u*g59hB{XLYX@mKRFX2}wuk*L(PxKTe=KRQ^F7{9tb;8KM>P_malUVN6{F%?z`286G%(YXq# zowClMP1DtuitMQWY2Ox9s2B;;v*J!>H)X%11&L_lmLkrfDqlSdDqg^M;!r^&Gv8?mmp=k_ewG>{9+AqAt7l*_AOxK3V)A`II~D| z+%R(#G{#dFn1~QS$E@Xy+z=KMj;s%NhqOcs*jZO&0JB=&__jiiT1;DS$@(xvt#P4k zIgVlfQVT4~>|~9&5Sxe=ihQLBCdaiz%ZkWa{dS)_DPUrx7JIK3(c|qYP2xOzFG)>m zTIuJCo~0*#a8v5lmT}|$U+M2*mtX8e=Z7hB0GhVQ;9}G-?jKWW(-`T7jTj?wv6}S{ z9aCIa4b*^eQ#c`)RR{B=;91EWF}Bl+*)o89ySU*kGcd_lF@ zuHl<32=ov4`bmqx68lI1OD$|%MzdaPf1pKlPOp`tF7LdnS}*XXV_G1jBM7&ha3rTf zU-ZZFe)G{&*NZB1U88Z3TBBk1zQz(+2!pANlRR@eYo`JKEzWbR412-o>2YE}sRMA7 z{E%&QDNUmWN-EGD>9AXX4MGpxyKz0+h2ihOTJ6;Zc~g%VkIu)u=N*Ip>dZRkju6azYi+jxDpT3Q3LTjmxkF&s z?i8UC4V2y^8oCRyNrm7;+REviR5IZbQn}~^kNMcSYoVz+n%`)ba`S}W?%#j5%hH4T zanck}D&k|&(JVuPM_tYvb;;xna~4#wKejSzX4rUK9)4tgnYqY(L7GyiSt77=OS;D0 zIRR^k2h13AJ(DfqSI)18khK|0W6>HDc%aQBxMSq2r^;)JBD$fcdb*!Hoa`ktoA(Ns zQ)lZ%y69azT*kWWkw>)8Q{SVsOxdeuE20*cP=d0RQ8JVygt z#r4@qC@mYL`Upf4{|$=WX)B!iD>i(3O{uy%)>p`!OyZGn{lHGb=ckp@nLyp|H7!XyPxdO=^uifp;cz`)B#b=Q$~UkS_O5iG zbXvH}U2C!u`;YMS(bTdTE9*$+U>5B+*6UbvAmrQy0pxic7^bM} zfTdc%j3WlgMR;Lk15kRSsG8ifyM4%`V=1?NrwO3rT(4TPG(sJBLPJ0jJ{1W$o@30X z@j{o)v7FkYkG(G|YkCaf$XvaP8;fWZPoL_bm4W{Ph~|b9CA`_R7Jy;=9L3~>*ZV8N z`?{sVWa!#W+>0-`l&OahrqmZg{`(#zkuNBc73%mv+OuFuRrJ6)=16YT7ue8R@e+pR zh@i{jo868ZBujeV|6Rjfv!M_=11DOuiar+R3BBi&YB;9(NYN%X8-Xt8r_+u^oSfLJ zt`QBMdm3Dj|K*eFLP3-UH-g2jpUk!)uH-dQF4nq@oozIr&efUxg{5Ik3Ebg0 z*JxXJv+kyD$DHKoH3q-~nG{LC&&Hgwya5^S+RlBJ$Hrt(qC~6T=~giXM_tA^OrUPEIKG;nxbp_0(d?=Yp$HMgg-m-e`>spyvg}<~DH(>ro z`-y(+<=GXPSwGVx4H!E!+nt^rq!B{EOCQv}-hLU6fD)CjNFTx})7qEox<9bGN)*LK z3Z1xRRXw+gBQ4%qe_hX+!M#>$m=Lrr_?16Fg-B;;e^8J-U?y%1TlAIqfu%BOGP3*M z4GTX;8_2(M*6Mb#VXblzXPe^;<;atfyUREZD`AHSk;bbc)R_Ky zE9=t++b?P#0(8ALife2L#^v4%ii0~0IHc-vJbnbC0c%*Ko^WZ_gOqrg9%h2u0=VW1SGbVFxY4_cG>&D-ex`9D; zF`nsArh06)nH1(`!9s>5xQ3ngeZKHn@fBJatgy2cbMsi+7cX4g&KT5OlgsKE!y6X4 zTE?Gehc?%^S@@3E6MV3xgrmthc_~U(EqcqLf-T3mk7>$yEd{zU2li(Vh5o!Nm$^6~`2Z+0&rvXd6#VRsopR9pmj7bEK!H#t-{#Qz(n;NPw*A=~ zS!#BPv4`xg#kSy+GtSo`h<3k~ z-j5)?HAPa;+5RQ|3npbbDlHac?>2st9;-r8)nx$MbIY#+fmz4ufL`VQtMd;7`|@*W z-H2cS>?hsT`%XnUH(dCG+vQnME74qn|4E6o3`U-=OF)$)AI&#?z< zpPS=fV?{W!U9aKELHb!D7LL_WtMFC%dLgSptGd8U_%^pg5cxZoJ~A9_DwKJu=c!Y$ zn!W@#%I@aue}R9IrZVR+$iPd{a9=6HA|t`Ii0e9V(b?v{ zh0_!E=K$<}#d+ge(*tG6SADErZvb`o;m#5NBS|i2U?=#bM+1RhFcNh z;>l2( zF0K{q5z<_ogk71lEyDoHx8TO3TN-Yy;KAA}(w;e5gf_0D{P|LoW|tF|vjANO&#w$F zl(LS}31Ywf*ARbZ6mx?T8GZ( zCDzJ@v%CYcJ)-;mIUUUphy-?`Hb^9aIud72ZvHMnTE_;(e?nz&~ockxy7 z?9`aYoo6?QGA9DlnuTBqO7tu}Fp8sJ2uOYuzS4;PGv!;+;;nu+mw$FnawjegJEvz` zI(yLfPZpX1UkkYDyvI>4E#4X$4<)C$i_11Vd3~rjJT5$7XUBVo1s_LHgmJwVp`2AQ zhdkU;#N*0(_XBQlbUxP4zcb*t>6Q=Fo^e{N=&W>B>a7q8OhT1E0v@e)63LA`VEIWr zyf^vV34bGDOKiq#vISM2$f~{?I|N(wARKNwl>M!a!KC*K3N5RLb)w%XE^ogX_h~MP zk`9{nK60uV@ImsR#*o=p_w_HvnThkAvX*uhfSZ zOC*OmwV8ScpD(;{Aq+wXs8?=UswJik26cWid_L_apm&V>A;|i3vpr)r!ou8{>G)Ua z`;PLiG3;zX=_`SZnS?z2$*XLF22J!(AEA~xA}OO?KAH~l{3a>23s7{nhp04?od0f3I_g~gU7jydXfa+` z_Gu@^yotB!tGA&qQe`h;?{o=F%CPV7gL}v!Blw7>9o89)%ZMt6(|0fAq_9k3_(e@x zJBorw&G**vcXO zR6q_0qT)@}jf)G^##3@SdU~tW+>RLC;VlI%A0UH@g<_jxbby0>KLTJCn?Ni*x7-Uk z_wR4Ks6MXZr@CU`NVIgyQb~9lnf%Z9nUJ9wsjuqsSaT`f=@g&UfJ=F^oE5xpZyDqHJenZBE_YAIutNn-}9 zAF*#orH7@sl>AUJxX5IuX=AOPH!b>VQ;L}A&aM3P`z()9b0;zB&c}TO zD$gZzNE-eAy{sqAbfs`+^tpk8QFPS;jB&7#(24zxPh5;@!psoPOyM)iR{I1WJMN0k zWW_Xf0v^%}10vgg@}88x15 zN#=_D0AE3Wt0Q38CsB0e3ePZBzxyXkV%9^o(_MoyUEF3j#52Kr%qpe16A7o!A$3Ev ztKxp3Tq-;VEH;E9aF$^X!cEM5fn8pvX`IfOETd;1cDx)-z!E^hsfE1xLYmo9XXE$G z_SkyZ#2GR!3F$a77x`6}4hdh!B1aC{ovs5{K{i;jz2laRY@E1xxh zKO!;b@VNvf&0(ZAIyah}c1(xz6f@1NqgB8?l^h_1|_~9o?=-S=#aiXgL~~ z80YFmMA%;G4PNGOU2#8F zHe;LVI~X8&VdbJ&CCnY|0~|1fUMEmO`4dt#BMI$WI}BeK@O1eQT3o=BmBG3$kA%3 zW*hB(UZ{avm+}YEHJ@iAc>KhZ9bVhVh=BmR_f_D<_dY@>rnDQgfr8!oDtSp(3r}*2 zVHAU%uhZ)H5SDaOog8C;qL^AD9%lFFa_)%#0Bh+3T;c5ps#i{708A!aOKP6DUcL?? z{s4nLqZQ)JK5Eap?WN+M5glnOyCeV+Q*-OqYNgf)ynC*w><&7cc#D9p7wHi~b+db9 z7N<8kh$q8Ow9CCCA_aW>eqMqPWVtA9YQ(n1ETQ~UQ`-@H427k~V*|=%6F0e@RCL6@ zO_+yTSKcQhD}K-AdiIxd>i46L`|*t&d{y6ce^YQ~B)>vF*-&nwcNQpC@~$(MS$4>1 z8pR}MGz~4!ibIWB;f2=68>Yr^b$@iRJ&H3HPV(k*61f?tC>$DAXgMP3<|G{@Bu5_2 zgL>u*Z#X@(xVe+n?fbr4z~}T{`x(o8)BDZSJZ)&132X}J!XE6xR?{?BvC??1*p4>z z+aW;!M|HqYIX~?z*j=NM*P4lI5C>r|D1M3Ss{40v)FU8Q*SDhW`Nq`zhPMk=EkYWe zSDnZ~!ixLvLeS{vtvasl2;(vtWibmx(%OW)oER(;=*~O8l^Rx9ZxdzkN;vE&z;zt5 zCTAbtG#g-!`=DCD>-J^$^$gp;|7A@}XU>7OWMz|zaeC)o3E0UQn6Vv7x1-@@*sT&YTjFPnt*0AHap4>{qm z=i82oKtLkMT8-a8)ZsV3_Y`-~NF<-oTu$p4-jG2zEDIl0 zK+Rs>17E!8Iw4%>(cC9MgV%n;Fds70f_u0k=f}BB7;=n%hp z8%(;IP_k=OAkIb4v1%gz0`2z9Nh?Y^g^anJkaY0vgH3uPk({mF>;d{v3PlXlU){vf zTD48A%CpM|CvN-vw(}N_@&#?aGLr?!7Z@P+29R~1v|AdkWt~Hkc7gPJz|WJ0G*9)G`G$OraaT^v z=5GZd^O0vqS*%v%D|^{%TsTQ&07}41YI*wea#Z}NtPM}!TR)Z(YSmlk1M)M5X~x{T zVbbB_$(EBL^^f66+Yo>G@svF`AI(}JqThq<4`B<4)}+-pwmDte&JLq71;?^4`o;%J z8*W3^gDNm=pt}>ALgZP%4d?a!i+pGW+U4Lm!~wqz0Knj~_5%hQC5qJyGNK8Ves?x< zn%mk=&Q`ib;cIR77*v?@nuubKcM{qfA{l_5BW=yqfo_lZMPWDb?McW{37t)7%_LK) zzvLGv{Q(i#4m?@L-ETW?wL$?Z0}n}=JB=gdA?u&VErh# zJTyaQ7A-?{@oxMtb~GPHsZ$t zi%vel!&F*tDy>QF<7+c+LkoA4lqXR>;8Y&Bf)5oni7t~s@m^0kBaqp8Hh~Rs1D%@V zAHz*~e1l%&o;a{XA3YB5N<`Ixz!b3-<|GalTpgz>7%eaE-r{xJs^(o;)y{3HwCEYb z#zpi}>MjmQ)VjL8fsVf@r56eO98=yPH%Fr^W>I{z?TxlP2Pt?Yw`Jflrm)?U|L{ci z7BN@c*Ie*L&cJDkO7>PdTUl}nq+9q^?s*x+Wy46ph1Gw!`uPGljuOZ8IPg-5)n5)S zsqOFu^?QDbv@)ZmT3Dm%c6(l)O#vHxrbw|(-)%7EhrFxMM?yWE^9$Chn@wRF4$L+kDJ66kDjB#+!Ke3d@2bu7*%Wm^!0-DO>r}#O) z_Q$E(7Zp>?PtUnKW#0JK>brX4tKb_c_71nMD@|eQy$lOfYMUZm&oJ-&>&IiwL6bLhvxht;5OYbi{#gO4E6TZ_6-U8MF9{Lt@V+6#FKUA)|Ls8|ZikGQp6Hfs$lr^J4lRlAm+6T@l8RQ{v;5I49=5fq0LPMuNhPSe5POKl6b@ITDp_- z4b6xRnX9MFbdypW5_P3%;H=!wuHLOB<%?ts3(m@bM%}(Lv1_z{8)Hl zH7G!6Q!*SheA$EOP{=CU7WG9jEtwH+h;aSA;G`jUnqK1J^c|xwDraxG-(Ia)yx)a? zTj`5zY9j_0X>Q(IPCU<2?2nk;EAYu=XG_>uz0catSYY|58(Zp;A(%(Z>Uv-FCC}Rdl9I^H)Ib_w=;xsW0Mo-6Va; zr-o>bz!;R@Cq&~jL*Ws0Bbkh<0ajCvrfi>qzws5vJaOgy`lr;Xn>m5oo+m>2gNo^R|8Vh=2xoVqVj6J~8CcxPhIz8* z^EagL4^m+&tFX}@!c#2nP8m1P7Z%T^c>T;l$YNd{|Cu=aajBjzYtd>8JRPGql7j4fcxN1ig?uX^4P5d31h} z&1@7t^v9ykcwC-QXADO+3msq-%K5-791smYIjF1~E?0k)bmE#4Yo-lSABRgn6*3wK zSYO_mVpjPqq*2Af_}aVDR7DH(CSHOh;L6Hosy8>QJC8~`F?P#mJ&_Ukp;)v(p*_0G zoOmW=U&^6XtmsUI8P;M>^?M`qgw+T_(2w?s(=nT?4lLgxRGMZvPqdBxqND$!HA4b+ zsp_+iIy1B{yrnq6x-8U)H(jz5)Jabg>$dli1<6=q>q9T~*IYWB)BWJ+L3SAC@O+nC z9IE5?1V$33lL?YYB_15yg)~{O#Fa0LRQNxx&l)pX?7eeXm4dTF*`9Qky`jg zG%n~s2D%218BD4J4_P94@&o-YXPS`{GM$#3lhx(c5PK5F{L>T7<8*PDdz$b$ z*)7vk+SL3nuYk-|8LQL$Y=7F)sF;a;lE-JSn^VEe`VCvFU~7SpU*|3Cw%$Mo1~pw& z-}r@+`CDTNy`GAFhz)`^s~0Q6>^T~oC>P75$T^IsfJd2y{mKo=IwevnJPI5)ZhVqD)~Xy?%di_aA;`D5$grHQjYgl9WkCa@LwdQ?$56 z_@Y}md)BaUg-6Ur`w>OHV(CD)GSw<@c8oc>XYqrqK1Qttjs$562h&6AeBBcvJdwXm zwWL&yx5{v2kL*>Z{h+HV7l0y6BQg1;T9o)2nCbs5+;;E5%DGok$pnK?uF9fG!8?7T z!hf62@F4Gz5<~&6F27X1XjYKVu*tbA$2Y}j=Ysq-S#Ty{oRQ~c7Wj%$Iq)kTntzQ+ zd1jl5d4X}QiN5fiTi}CNeK9NI8$biWLDhM_=sQ;G+;gZ4mqwt0_xvMN7&=Toh2f(9 zM@hcebV7NGPGb;Jgs~Du<7FV@o`DNiQvv-r@33QqyTV%%^#XXwq*-dNfK={HbkCf{ zTP=t)k1|m4lyI1VxuTpTas@d}v1+2O{W{;TPKKJbd3Y>Gw~D8K*#zHd0-2MsnbXz} z$aL<$ctp;ZR%aTqaB6bP%2#Ek8Y)L#JOuc$*XeP4&Fcp04FO#(+Ew}|EDt=j5iutx z6~kczjuOLC357^2OD_A)bB^R*_^46+1xmmN9TTIp1O_o>Px?F$2N=h_wXv@OjvM5J z%wima+y7(x5wHturt+e-cTt-g^h;PJQiHuMEVXis1|NgA_k9$#mH(MdXX{(B0q#tf zcK4W;fBT%qt62GrDePjC%e`^WrzN=H2fuBT%^E_ljcI8j`PwL z=g(buVC*F#5<$Ho&6^c0*s;YT1Ba&$U;kJbtUBM-FEZ4D+21T)N1izIFqdp$p&wM$ zWovW@yGHKXYD%79`R(y(k$`~~bm65EoZir$q`t)A%oK%ZDVBmD%Xmj zwJ^&I&7%Wpe@034p0qqevOQ#?gJE;n-nv_^;-=i$?|H&pb&X!j{#8iXV0)#vA71{T zZ%Bcfy}%b~Fjw)|sP)zwsDEjB?}S{+HOkh8#12}2sa@v`;ZaLPYyh7jLLj(Kef2;< z^kWY8FS4|rilbkLt@XB%XsXZIXg&R+6CDnuMHyP5)aA3nG1$_y?5Gm#Q6mp?~{ zws84_yDru5H$(?^9pU}Y%BNheZ^-A1quD#4@gASe$R9`9kIFjmt}lpMm&~u$lf)&0 zHDR*-jC*Nx*9t4QCw>O*-g$Y9p?BMyy}p$38&TcKT>fNi;nw>)GjDbd`__29>`wBeriE}!69fB8YskSg~eI9 z+2QkJOj_*6dK(u9fFMsDoY;KU841Ct-UBt2GlYsvWPh<;#lHLj`O+v$UhiSI?mL>B z6LY9P(>s=P4Cb?YJz*(35K}Hyh>wNDbU2heFQhg zueRa-wOhEfO8yZ>(?ZeJ;(|x;rt+EbMNHOk_X+vMUG5i?EAn~N-1}w$8w`4LWU0Oy zj$5-l!Cv!e{FP~qRkCe`t_k*9`$zlc!T~@GL_~Pb*AN|XKqBlp2UbBYp|wP0x@)Q3 z{SC7cdX<*1!ib&T{y`4pQ=4sSpl3Tc*YDZ#;@b!GYanIax-Wt^-=2EoLqS>MNbpR= zO)m2GQI- z0n~RgPLmF&>^6@Y&l_67W2@Hn!?jeOkda75t`H@L4Hm$`nIk}AcL;5F(I~|;$h%x* z`lML*RTuhS?Y55?n)zF|C7*o5Z&8|bqvjNci5H|f3#Dl3e;|L#QcT$&cz&dH%FC0H zXN30b^vvjJ*`8bOJiAr8s6pia~;L|5gF6 zgb}X+9MV=tXe*BC=-g||52Di^a)4txpb>N?xaw;I3^9i?$?v=nGeO{o)I z^&pGZR~&-?&_{@W+Xo9RMfiiJ(gb?eR76XSK2-f1=*a(v)|-dLgY5rJAL3~DaV^CW z9Yr(}8h8Xv@{jp}O6P%LL>oA&qx7)V15N)sh3kT+(8`+VT68ZDB5*C>gO|14yFyMIPNxDId#srwCopB$^PaviJ@>s|^~xzrX)ij(%ukDAc_!cuZFres_1TBsp|_b06_}3H|i$ z?od!wcz^ei{EIsP=z*uzsecb2$9Ga~l zdrMz+MOS%jdg2UqcMp^p0!xn^AKyaWj9p&d-J|ZEEvILv&jUObn(Ges_pXa_wypIi z2m6i<)i;eG2tU{PwdKpcuA{x(tFzNP%XgEW4zp3gD}i1Mc1BZGrF#w4`@Nli5BIMR z4{k;Wk9W4O#z#+FZDw@9W8ncy$#H8ACet9P;q!CU6ynra6S2K{^3A)C70wlpbd;f+#DQC))f4>I`?+|4m4iS0BGm3Rz8?!2wnPXnW5hQhl_w^cZ{IlYhS;Cl|*LXNHtS@ml z8r06(mufZ7jhzps`JEU?k1LRXX@iABl<}e%#k<79HIs_M4bHfv-d&8@8%c?tZx7OM zvCq)jLAr*5Q<~t!>zkd?Kd|IORX5oqH(#)JA&pLb9P4E=TRwhHoG@7oeKIP zv7h8U70V-nm-drpovvJeU+!#Sv*ABd%VnOvE84$YY=EJJqfS(PaDt7?Cz`nC;r1tI zzQHUbmi_w^vMz@DL2&;-ZBjX*T*O`YTG;SDtt`w^P>iM~#DJxsasE9)T!(g3woUhP zZt|No!zrK&Rxpqj7X2o6QrhkV=N?WqfCHb34BK8MgiD(hu{A3}5{;C4HFU-XgL_JZ zy$rgNl<_!ceN(Gb4C$46E<+}ttdPw=&4v`)38oq34}u-oJ%w9EPmfp&qkizltAvFA z4k5py9|w&8l8AqfiAQWNSPgm(V%`=vHDAPuS7mul%1I{DIOqM4*_q8t?>P_keuqWYG zGSPa)LL*Myy@IX>z7cp=AbIeoK$ho4gcuB$Y+$;^X;ThM?BnckNGUXvK`c!vdito` z!A-+v-GNNrDZ|r6n@zBzBjob)?<)@ez*|;%hf!h`^Zq2E01MdfHVe2z9MkWK)T(X~ zu1D==A;Q9ozsqMn-wOEK^{ZM7oX6|@0sjTBsx-3xxn~Ah8mCbAY3HDpP05$PM{JVP ztO8lb@(LH`O?LOT@o?}7O{xa)Ve#SklT_8Lf#nW;e@%s)VB z3?I0>8tZ_CrNFzTDjyUgjM;g_i~oiEJ6rPzAv3W=Ukf%d9a*jjd)CW&Hvb}4hIs#CH#J}(GNzh)f!NHBkL*$-_n0A|k$t!|G5F|Uu-Kl%WKr^k7es-M*+MP5?eZx>p{wI?+SkER?nq71COM5K zlZvTBi8V zjfAmIwr|#~q@&r5v(~?YP9Oy!fVR|yRa0l(?)pJDEvS6zAnYYv#wB^<8W`my=7p=m zG*d-DOoEIXYDBidE8XtzoCbR*$4L!Nug&o;=9lh~3B+YkVTrx9ryOLWI6ld1M1=g3 z5*Kps3bj)|<<|;D$k8TOCL2un0gQ}2vL4yH$F|yTvKp-|>exHS z$^XPX$9umR5snd`*E(3(uSqvyFf7y=IKM9M5` z0osCW7Mn)Mp$?TxvDSW6Q+$v$t~E#bvipz02vlPf4dJQs;6_rNQ2pw%fxgew7Rh34 zN03BOV@*kVsK@9tbI5LgLoBEW_pxmK1S$4w`naApEQQkIR!slAYyF%^4`s6CHt)6^ za_rYtVO2yP%2tvA#Mh9sFOK`XAG`9v_a~*q%&r=NA!4}Kdji{946V)V{ zt@(jbBvCaCUSX3Vw&vBy=O-7sQE%@qwQaHEE+&!Ar^Y&vet%S*gWa(lEL$a0*j`(( zYcRV-|D^BGNLGKE{fczv2O{4!nK zQqXgVqIP3lvf=49Fd+DgAT2aBBG+9b*N=pkT2;^8dJXzx$c5;2;3rLCL#-W4rDI*B z4PCBLF?0yZv%7V$$8yTl_gcA2fRp3Zg2p$xK><42;_ZdeZ?mO^B2`u49v|p~0YMiy zyE6!A9dC1%39z$xX=ZxAYeSH+-xTjZRbIMa2;E)_|D1`crj3Qo_vAw!_P#RHwRb(t zBvP>MM#}TPvK>9iBsG1#^4BUinZa#4W?TnHVnfi1R(rZ<$D2{sD4hW7VeiYDorO5xScO_nF@eSq@8~F`+UijOz zpaUqS#|C4r4`$%fri3!yn`rWu^oRwI50nhD1XopfCAPB>4&Olk~R7VviqDzGHwXZ1Xk5Kc1KonC`Tw7AHMktHQ+(YNC6NU!_#pXn=(aEpobayyg^ zA-v|0qwGD|b))GoMz?GJN|`XKSfy8HiR=w!zmZtxLWG#LrTZ9}85^_M&Cv^xzDxoU zArkx&zM`Tnmtof}n33OrFt5_V4Kp1Dta9ygs%g^G&UpR6P|ei=R{$h_`O4Y2-th=A z2;Y8DrbXj!&_i$AG}K8t$QkL8;mN|g-03U>+9r1?xPQ-kEiNC01!Zu-IepDk0TevIsG2fB!#gXx?jI=`qobdY5%yPNStlX zutqwF>%3Sd@-3aNVQ%p+n_8EH-RodLkUP(I8mpM z@c1}M;D1?Q-hAF_lrP@qG_oqa-d#1O?NDbWrwsvsJFoizM^S}CVnTE;WYcKs?_p#Wr z$lll3w(G61EUK=>NGAJafkw(S1wGx`(w4+H3YSA6B~o(6Wjxn|Jj_1SRnp3FhcfoM zP2L-UT3lRz+?d-Fsd0Ax4q?zt**EErCQ;bj^<^&DJ~4YC4YlTUw05mdFU=H)i)(slr3Ml9~_vTUySRnXH6r_z3I|Hbvlk4QYkS;s(x!`7 z))l%aD~4H;cE!vwlGuofc`6!Ej+qw~0lXXgYMY2H$FYY_I+=MpVt6vLI4-Ec)l8)R z3=fx-kiwFa7nB;YK|>FCu`d5p@+Wg5SD@+o6Hpb=V^Ra*sUh5q;FtM-TCy>}6Xg_S zW5`nrC6OX6>u&u-Vb|_`;WA-!&CdwEb?VjL#Y%}_MczKzeF}QaDr7b6QQew<+Nvh*u%}nM8 zAD2JJ6W45oX9^T!EZ~$IHZbe+)bj??^iV`y6g~q;Z$P{G{tI#a>GRsMsvy(jC0QmAtPR&#zcVG%PI7+n zQXmEPX>V57J^zT>0(r6Ohu6CpdEXLo_|sTPVp~4r=+NNc3xCP@b-C{2G9wtTWifPa zGQp_+J+?{Bv@;5Y)QKL?jq1qHu#UFs=#SqmHA7j{-C8lPRet z46?qb)#)}D&`#r{3P#C*6(*aQwxuIvu$Wn@m(ftn0s~Sa<7%(#?d~7W<0<-<>+$67 z?lsQ#oei33;GxAY(;X^q%c2HDDRC&4wfW8G+NprB+HP-q6E&oX#@5YR9>>x*sq>2t zyesqE*>*gp>JEwoRNL|;0O(wyw1M7Z`XN_s=kB`hcFO4xwy45}=A-`ic78)jG zt(t0PCzZ334RzkIm`i9|O50|tiXFR(&=`r-2mA`EsUaevOPUtWZzq66!d#3|5kxaR zJ2$0&zU*cAuNBW3JijLQeYwJRQ&n#>7=v&#!QQrtQmhDw6#!#@o{yRijK#cF z39&5*8u732fjkb%x0L%xDP-HXx$_24H6v)Aw90{Nb?W;vg>}}bYBRNUciOF>u%JI> z++)M+aBqUpe7;2)aX2NC!}9`37v?{(#C|sW6X%@5@cj0QGzmjP%n+w(g2H^8Qnhxg z?bI^7lVz6Uaj(9yU>q#$O<|?%_s41H<*YkgO{YViU{xHWo=ta?(F_bM^COYZJv}~s zfw+}ECrW?-PnBz?iqD^n3*+fWfdl}lDVzL9%R;f6QB; znIp_@!raSW<5OOFlnRj4uXCrS_nEP3+(!_%PCp)0=(oO#Pp|BzRm4Y=g3leop@^wJ z?iaF^f!A?78htmd(Ub!^`d@mW71ugTa-k|N)H0n`?k{R~uYiq&yT$m8hP<1*$#KfH zDYnk+nvvBu<4~ch4j7*M(`zbMUF$re_nun0@5-#8Pto+eQ?o4@n=5uS@vaIrqLlyP zc2c1VxI8A;XhbIq&b_%)HKND5nMW@JBFM!1_dK8GTE(O!?8=hrE&oT?2{gLb@^>A+ zJ+)KGgQ(NlJqLf^j_JqCjLRLe&Pn^-I1b->uY9OX7e#RtzB&w0Fx~&LGv(CRpP`>t z_n{B=Ys;|4;Rgw2smbfBzTCnS#f_42lD*;HZ>+F;%uZG!sn9U)O!VJ73@}h#p-kR1 F=zo~QqT&Dm literal 0 HcmV?d00001 diff --git a/public/logos/kucoin.svg b/public/logos/kucoin.svg new file mode 100644 index 00000000..68462f47 --- /dev/null +++ b/public/logos/kucoin.svg @@ -0,0 +1 @@ +KuCoin \ No newline at end of file diff --git a/public/logos/mexc.png b/public/logos/mexc.png new file mode 100644 index 0000000000000000000000000000000000000000..3f783c7e268bd0661facce19065ea7b5d07594cf GIT binary patch literal 12812 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 LMXFN5*#Cb4hZK6O literal 0 HcmV?d00001 diff --git a/src/lib/logo-manifest.ts b/src/lib/logo-manifest.ts index a62b8078..25cc7214 100644 --- a/src/lib/logo-manifest.ts +++ b/src/lib/logo-manifest.ts @@ -187,6 +187,16 @@ const RAW: Record = { paradex: "/logos/paradex.svg", aster: "/logos/aster.svg", + // ─── Perp funding stability cohort additions (bench № 043) ─── + // Sourced via Mobula CEFI funding-rate aggregator. + bitget: "/logos/bitget.png", + coinbase: "/logos/coinbase.svg", + deribit: "/logos/deribit.png", + gate: "/logos/gate.png", + kraken: "/logos/kraken.png", + kucoin: "/logos/kucoin.svg", + mexc: "/logos/mexc.png", + // ─── Hyperliquid HIP-3 deployers (bench № 035) ─── xyz: "/logos/xyz.png", vntl: "/logos/vntl.png", From 2bdcd5769c1a81202465aaa9a328c94944c475d5 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Fri, 26 Jun 2026 15:05:13 +0200 Subject: [PATCH 004/144] fix(perp-funding-stability): trim methodology[3] under 500 chars --- benchmarks/perp-funding-stability.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/benchmarks/perp-funding-stability.yml b/benchmarks/perp-funding-stability.yml index 10f92e7e..ee15d5fb 100644 --- a/benchmarks/perp-funding-stability.yml +++ b/benchmarks/perp-funding-stability.yml @@ -44,7 +44,7 @@ methodology: - "Source. Same upstream as perp-funding: each venue's public funding endpoint polled every 60 seconds, normalized to bps per hour and per 24h hold, sign preserved." - "Metric. stddev_over_time(perp_funding_hold_24h_bps{asset=\"ETH\"}[24h]) per venue. The 7d window smooths intraday noise but reacts to regime shifts within a week." - "Sign handling. The underlying series is signed (positive means longs pay). Stability measures the swing, so the ranking uses the unsigned stddev. Average direction and level are the perp-funding bench." - - "Cohort. 15 venues across two upstream sources. 7 polled directly by the harness on each venue's public funding endpoint (perp_funding_hold_24h_bps): Hyperliquid (onchain), Bybit, dYdX v4 (Cosmos appchain), Binance, OKX, Paradex (Starknet L2), Aster (BNB Chain). 8 sourced via Mobula's CEFI funding-rate aggregator (perp_venue_funding_24h_bps): Bitget, Coinbase, Deribit, Gate, Kraken, KuCoin, Lighter, MEXC. Both pipelines normalize to bps per 24h hold with sign preserved, so the unsigned 24h stddev is directly comparable across the full cohort." + - "Cohort. 15 venues across two sources. 7 polled directly (perp_funding_hold_24h_bps): Hyperliquid, Bybit, dYdX v4, Binance, OKX, Paradex, Aster. 8 via Mobula CEFI aggregator (perp_venue_funding_24h_bps): Bitget, Coinbase, Deribit, Gate, Kraken, KuCoin, Lighter, MEXC. Both normalize to bps per 24h hold, sign preserved, so the unsigned stddev compares cleanly across the full cohort." - "Success. Same freshness signal as perp-funding: perp_funding_venue_ok_unix advancing within the last 5 minutes. A venue whose scrape stalls is tagged stale on the row." - "Cadence. The upstream funding harness polls every 60 seconds; Prometheus scrapes every 30 seconds. The 7 day stddev refreshes on every scrape." - "Failures. A venue whose upstream funding feed errors keeps its last gauge value; the leaderboard tags the row stale once the success gauge drops to 0." From ae6a26e40956d968817258d6f3b4dc32a5e4a06b Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Fri, 26 Jun 2026 15:08:44 +0200 Subject: [PATCH 005/144] fix(types): re-add venue dimension to BenchmarkFilters + spec schema Dev was force-pushed after PR #730 and the venue support in BenchmarkFilters (materialize/filters.ts) was lost in the spec/snapshot refactor PR #739. The worker still references f.venue and dims.venue, so typecheck failed. Re-add venue across: - BenchmarkFilters union (load.ts) - parseFilterSig allowlist - Spec.dimensions schema (spec-schema.ts) so YAML venue dim parses --- src/lib/materialize/load.ts | 5 +++-- src/lib/spec-schema.ts | 8 ++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/lib/materialize/load.ts b/src/lib/materialize/load.ts index dd77a6b4..370e4b97 100644 --- a/src/lib/materialize/load.ts +++ b/src/lib/materialize/load.ts @@ -42,6 +42,7 @@ export type BenchmarkFilters = { chain?: string; region?: string; kind?: string; + venue?: string; }; export function filterSig(f: BenchmarkFilters): string { @@ -58,8 +59,8 @@ export function parseFilterSig(sig: string): BenchmarkFilters { if (!sig) return out; for (const kv of sig.split("&")) { const [k, v] = kv.split("="); - if (k && v && (k === "chain" || k === "region" || k === "kind")) { - out[k as "chain" | "region" | "kind"] = v; + if (k && v && (k === "chain" || k === "region" || k === "kind" || k === "venue")) { + out[k as "chain" | "region" | "kind" | "venue"] = v; } } return out; diff --git a/src/lib/spec-schema.ts b/src/lib/spec-schema.ts index ab57dac6..d57e8694 100644 --- a/src/lib/spec-schema.ts +++ b/src/lib/spec-schema.ts @@ -344,6 +344,14 @@ export const SpecSchema = z }) ) .optional(), + venue: z + .array( + z.object({ + value: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/), + label: z.string().min(1).max(64), + }) + ) + .optional(), }) .optional(), From 6d49897443837eafd1798a31c8da3901d9fad5dd Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Fri, 26 Jun 2026 15:24:27 +0200 Subject: [PATCH 006/144] fix(logos): coinbase + kucoin colored PNG (was monochrome SVG, invisible in dark mode) simpleicons SVGs are monochrome (renders black) so coinbase and kucoin logos vanished in dark mode. Replace with the colored 200x200 PNGs from the CoinMarketCap exchange CDN, same source as bitget/deribit/gate/kraken/mexc. --- public/logos/coinbase.png | Bin 0 -> 2027 bytes public/logos/coinbase.svg | 1 - public/logos/kucoin.png | Bin 0 -> 3832 bytes public/logos/kucoin.svg | 1 - src/lib/logo-manifest.ts | 4 ++-- 5 files changed, 2 insertions(+), 4 deletions(-) create mode 100644 public/logos/coinbase.png delete mode 100644 public/logos/coinbase.svg create mode 100644 public/logos/kucoin.png delete mode 100644 public/logos/kucoin.svg diff --git a/public/logos/coinbase.png b/public/logos/coinbase.png new file mode 100644 index 0000000000000000000000000000000000000000..9c7f8393e1184f437ec27e856f799e0e33e15d50 GIT binary patch literal 2027 zcmbtV=~ohn9wjwY7NJ})bHOywQj|o)1s7DbW!%bb+#M^Lv@(})z{=eMwX$r)To5Kp z3pL3SwcN{)g31&$XI#TwGtI)r>AiQ}Uoao;{oQlF-E+_R-OD~+9#D`D2mk;;4|uxy z{kSjw0~O#8eh|bh0|1mC9&mFxN(rAY9ZGrU2`A|(7{b$CkxD|!mk)+`#oO)I(|Q5> zEqKyf+&PINJ1V}_tLJQVdN{hd#BRx&lj#90Ka?;Ko@r; z`Qg~0C1rCpT9UfHb(p2Xk@ep-6w8u9i`IF6LCeuYk5Wav^rSalJ&(?0s)-$>bOCt!P0kX3}>@@ro81`Yh0R*PJPh_Sm8&GZpe_e zG^k4}`LfOS`ai;0d9JZ>8#m4)mbjsF{TLC+a4qHt51ZR6`RsFOXT~7=ClLv`c50Hf zT(b1l*$6Iww1dNLLaCY-6mT^*8$Z=A zY@y zT-sY`1T`?tntE5*0JNMrT$brT4kx5&mA2EbB80UF@3Ew#=$wGBVqNO8v0Xia1!0~r zD0|{m)l;&XqKKNJAK&^|5I?vloZjW>_NXuM%QiJwJ2Z)rV z76D0`hc&)A%e$*BVA5%W`EOxzRh?}SA7|QcS&ZGuI)21U#8Ua09I(|SU5n|zR1bn#9D0*}`mKo~OL0`f*wMsAUa;7jgev2WrFfLfxLvX=`-< zi^>Tl8y(nFhOFQ_yeXL%s$EX)A3Lytq8%F3?vIvTAq{lgN|IRm(MOl{JZX(M9{YQ)68sN%IrV(LeJW#CERxDdTKK=iYb zQ(xa3#;HVKwCP-|IVh$J#X!Tg=>1=+cu~28L1<`t*T|e&95Q-uvn|h$$I3OIE5buU zchZC2E=0BIHooagu#=wcdXr2YiHAS|^&FHK2|q+G!gE40T>9>%Jh`0vM`8vr`^o#= zu-nDXif%2#RC5ahPTDTa+wWB^){$LJ%;tf3Qyu)6ZH@%LMpl3q*bev9d#I<~E!)uu)2u)e8#=_p<_-W8ruOSg2v2b7A%`DqztBDd)D*$kt4Ng>3oxM^}bdY)vWN>#Tfy7H{*48~{0kD-}m5*3cZ4=4eoS??>d@>_kn9~rF5cb zURPpp<4%xE4Y8eJeh%T2#gL#pt($SrPEi0UlI(J2P_6ek%g8z5bjJV(KTE9@D#*$y qpPHLh>ZW$5JmZc5+iYE}|5kobzDr7MvD^GH;(!D0UT$1sWcojNCoinbase \ No newline at end of file diff --git a/public/logos/kucoin.png b/public/logos/kucoin.png new file mode 100644 index 0000000000000000000000000000000000000000..41156cba2e196f283ce0d63bbd9668a479ff749b GIT binary patch literal 3832 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 literal 0 HcmV?d00001 diff --git a/public/logos/kucoin.svg b/public/logos/kucoin.svg deleted file mode 100644 index 68462f47..00000000 --- a/public/logos/kucoin.svg +++ /dev/null @@ -1 +0,0 @@ -KuCoin \ No newline at end of file diff --git a/src/lib/logo-manifest.ts b/src/lib/logo-manifest.ts index 25cc7214..1f383257 100644 --- a/src/lib/logo-manifest.ts +++ b/src/lib/logo-manifest.ts @@ -190,11 +190,11 @@ const RAW: Record = { // ─── Perp funding stability cohort additions (bench № 043) ─── // Sourced via Mobula CEFI funding-rate aggregator. bitget: "/logos/bitget.png", - coinbase: "/logos/coinbase.svg", + coinbase: "/logos/coinbase.png", deribit: "/logos/deribit.png", gate: "/logos/gate.png", kraken: "/logos/kraken.png", - kucoin: "/logos/kucoin.svg", + kucoin: "/logos/kucoin.png", mexc: "/logos/mexc.png", // ─── Hyperliquid HIP-3 deployers (bench № 035) ─── From 5cdf646fcb942fc996a9c2a0d6d5d423e5f38de1 Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:31:16 +0300 Subject: [PATCH 007/144] fix(main): restore src/lib/categories.ts + binance.svg mapping (#752) PR #743 created src/app/benchmarks/category/[cat]/page.tsx + benchmark-grid.tsx referencing @/lib/categories but the helper module was not cherry-picked to main, breaking typecheck. Also re-apply binance: /logos/binance.svg (was reverted by auto-merge against pre-existing main entry pointing to /logos/bnb.png). --- src/lib/categories.ts | 110 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 src/lib/categories.ts diff --git a/src/lib/categories.ts b/src/lib/categories.ts new file mode 100644 index 00000000..b1eaa988 --- /dev/null +++ b/src/lib/categories.ts @@ -0,0 +1,110 @@ +/** + * Category taxonomy for benchmark hubs. + * + * Mirrors the closed `Category` enum from `spec-schema.ts` (the source + * of truth that every YAML spec gets validated against). Centralised + * here so the `/benchmarks/category/` routes, the sitemap, the + * client filter and the meta-builders all share one slug ↔ label ↔ + * description vocabulary. If a new category is added to the schema, + * adding it here is the only other touch-point needed to surface a + * dedicated hub URL. + * + * Slugs are lowercase, kebab-case, ASCII-only so they survive URL + * canonicalisation, RSS readers and JSON-LD @id fragments without + * needing encoding. Labels match the schema enum verbatim so the + * client filter can compare `b.category === entry.label` without an + * extra lookup. + * + * Descriptions are SEO copy that explains the category scope. They + * land in , OG description, and the H1 sub-paragraph + * on the category hub page, so each must read as a standalone sentence + * (a) without the surrounding template and (b) with the category name + * substituted in. No em / en dashes per the project copy rules. + */ + +export type Category = + | "Aggregators" + | "Bridges" + | "Blockchains" + | "Trading" + | "Wallets" + | "RPCs" + | "NFT APIs"; + +export type CategoryEntry = { + /** URL slug. Lowercase, kebab-case, ASCII. */ + slug: string; + /** Schema enum label, matches `Benchmark.category` exactly. */ + label: Category; + /** Plural human heading used in H1 + breadcrumb. */ + heading: string; + /** SEO copy under the H1 + . */ + description: string; +}; + +export const CATEGORIES: readonly CategoryEntry[] = [ + { + slug: "aggregators", + label: "Aggregators", + heading: "Aggregator benchmarks", + description: + "Live OpenChainBench measurements for crypto data aggregators. Compare quote latency, market coverage, and freshness across the providers that fan out queries to multiple underlying sources.", + }, + { + slug: "bridges", + label: "Bridges", + heading: "Bridge benchmarks", + description: + "Open, reproducible benchmarks for cross-chain bridges and intent layers. Compare quote latency, end-to-end fees, and route coverage across the bridges that move value between chains.", + }, + { + slug: "blockchains", + label: "Blockchains", + heading: "Blockchain benchmarks", + description: + "Network-level measurements that compare blockchains head to head. Time to finality, block time, gas estimation accuracy, and validator economics on every L1 and L2 OpenChainBench tracks.", + }, + { + slug: "trading", + label: "Trading", + heading: "Trading benchmarks", + description: + "Latency, fee, and freshness benchmarks for trading venues and execution providers. Perpetuals, spot DEX quotes, oracle deviation, and Polymarket data freshness measured continuously.", + }, + { + slug: "wallets", + label: "Wallets", + heading: "Wallet benchmarks", + description: + "Benchmarks that compare wallet infrastructure providers on label coverage, address resolution, and the data layers that power production wallet UIs.", + }, + { + slug: "rpcs", + label: "RPCs", + heading: "RPC benchmarks", + description: + "Live OpenChainBench measurements for RPC and node providers. Compare capability coverage, transaction landing latency, and reliability across the endpoints that power production dApps.", + }, + { + slug: "nft-apis", + label: "NFT APIs", + heading: "NFT API benchmarks", + description: + "Benchmarks that compare NFT data API providers on collection metadata coverage, freshness, and the indexing depth that production NFT marketplaces and wallets depend on.", + }, +]; + +export const CATEGORY_BY_SLUG: ReadonlyMap = new Map( + CATEGORIES.map((c) => [c.slug, c]), +); + +export const CATEGORY_SLUG_BY_LABEL: ReadonlyMap = new Map( + CATEGORIES.map((c) => [c.label, c.slug]), +); + +/** Slug used in the `/benchmarks/category/` URL for a given + * `Benchmark.category` value. Returns `null` for unknown labels so + * call sites can degrade to the unscoped hub instead of a 404 link. */ +export function categorySlugFromLabel(label: string): string | null { + return CATEGORY_SLUG_BY_LABEL.get(label as Category) ?? null; +} From 1028641a012d1506cb5ed55076063ff321c53b27 Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:34:30 +0300 Subject: [PATCH 008/144] fix(seo): blacklist hex builder + shared composite filter (#747) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sample-health): wire UI badge + API surface + insufficient silencing (follow-up A) (#741) Completes PR #732 (sample-health system) by surfacing the computed dataConfidence/sampleHealth/expectedN to users + machine-readable consumers. Was dead-code on the data layer until this PR. UI (ledger-table.tsx): - Filter rows tagged 'insufficient' out of the sorted ranking. - 'Low sample' pill on rows with dataConfidence='low' + tooltip showing the actual sample-health ratio ('45% of expected'). APIs: - /api/citable: returns expectedN + dataConfidence per row. When bench-wide aggregate is 'insufficient', returns value=null + leader=null (refuses to crown a leader from undersized samples). - /api/stat/[slug]: same treatment + drops 'insufficient' rows from the machine-readable ranking array. Per-row sampleHealth + dataConfidence exposed for downstream tooling. Library (citation.ts): - citationCandidates() drops 'insufficient' rows from the leader candidate set. - fieldValue/leader/headlineSentence return null/'insufficient data' when bench-wide is insufficient. - isInsufficient() helper preserved from main (used by hub cards + /api/citable hot path). Effect: bench pages where YAML declares expected_n now visually communicate sample confidence; agents/journalists hitting the citable API can't accidentally quote a leader drawn from 3 data points; the methodology page's 'n >= 1000' claim becomes honest. Co-authored-by: Florent Tapponnier * feat(seo): /benchmarks/category/[cat] hub pages + BenchmarkGrid lockedCategory (D.2) (#743) Final cherry-pick from dev-archive — adds 7 new SEO-indexable hub pages (one per benchmark category) that target long-tail queries like 'best crypto aggregator benchmarks 2026'. D.2 was deferred from PR D because the BenchmarkCard ↔ MiniChart ↔ BenchmarkCardData type chain had cascading TS errors. Follow-up A (citation.ts isInsufficient → InsufficientCheckInput structural subset) silently fixed those, so this lands cleanly now. Files: - src/app/benchmarks/category/[cat]/page.tsx (new) — server route that renders BenchmarkGrid pre-filtered to one category + breadcrumb + ItemList JSON-LD. generateStaticParams builds the 7 hub routes from CATEGORIES. Each page is fully indexable (canonical, OG, etc.). - src/components/benchmark-grid.tsx (updated) — adds two optional props (lockedCategory, allCategories). Backward compatible: when unset the grid behaves exactly as before (used on /benchmarks). When set (category hub routes), the grid pre-filters and shows the full category pill list so users can hop between hubs. URLs that ship after deploy: - /benchmarks/category/aggregators - /benchmarks/category/bridges - /benchmarks/category/blockchains - /benchmarks/category/trading - /benchmarks/category/wallets - /benchmarks/category/rpcs - /benchmarks/category/nft-apis Each renders the canonical bench grid filtered to that category with its own H1, meta description, and structured data. Sitemap pickup automatic on next regen. Co-authored-by: Florent Tapponnier * fix(seo): blacklist hex builder slugs and shared composite filter Drop 15 anonymous 0x... HL builder slugs and 5 dead composite slugs from /products listing, sitemap and individual /products/ routes. DEAD_COMPOSITE_SLUGS now lives in providers.ts as the single source of truth; related-providers.ts imports it. Bumps providers cache key v3->v4. --------- Co-authored-by: Florent Tapponnier --- src/app/api/citable/route.ts | 32 ++-- src/app/api/stat/[slug]/route.ts | 95 ++++------- src/app/benchmarks/category/[cat]/page.tsx | 112 +++++++++++++ src/app/hyperliquid/page.tsx | 12 +- src/components/benchmark-grid.tsx | 89 +++++++--- src/components/ledger-table.tsx | 85 +++++++++- src/lib/citation.ts | 183 ++++++++++++--------- src/lib/providers.ts | 48 +++++- src/lib/related-providers.ts | 22 +-- 9 files changed, 474 insertions(+), 204 deletions(-) create mode 100644 src/app/benchmarks/category/[cat]/page.tsx diff --git a/src/app/api/citable/route.ts b/src/app/api/citable/route.ts index 03d6b55c..e1283e1e 100644 --- a/src/app/api/citable/route.ts +++ b/src/app/api/citable/route.ts @@ -2,12 +2,7 @@ import { NextResponse } from "next/server"; import { getBenchmarks } from "@/data/benchmarks"; import { SITE } from "@/data/site"; import { AllBenchmarksDraftError } from "@/lib/spec"; -import { - fieldValue, - headlineSentence, - isInsufficient, - leader, -} from "@/lib/citation"; +import { citeBundle, fieldValue, leader, headlineSentence } from "@/lib/citation"; import { clientKey, rateLimit, tooManyRequests } from "@/lib/rate-limit"; export const runtime = "nodejs"; @@ -52,21 +47,29 @@ export async function GET(req: Request) { throw err; } const data = benches.map((b) => { - const insufficient = isInsufficient(b); - const top = insufficient ? null : leader(b); - const status: "live" | "draft" | "insufficient" = insufficient - ? "insufficient" - : b.status; + const top = leader(b); + // Insufficient aggregate: explicitly null the value + leader so + // downstream LLM agents and journalists do not quote a number drawn + // from an undersized field. The headline sentence is rewritten to + // "insufficient data" by headlineSentence above. + const insufficient = b.dataConfidence === "insufficient"; return { slug: b.slug, title: b.title, category: b.category, metric: b.metric, unit: b.unit, - status, + status: b.status, value: insufficient ? null : fieldValue(b), - leader: top ? { name: top.name, slug: top.slug, value: top.value } : null, - sampleSize: insufficient ? 0 : b.sampleSize, + leader: + insufficient + ? null + : top + ? { name: top.name, slug: top.slug, value: top.value } + : null, + sampleSize: b.sampleSize, + expectedN: b.expectedN, + dataConfidence: b.dataConfidence, asOf: b.lastRunAt, headline: headlineSentence(b), url: `${SITE.url}/benchmarks/${b.slug}`, @@ -74,6 +77,7 @@ export async function GET(req: Request) { ogImage: `${SITE.url}/api/og/${b.slug}`, source: b.source, license: "CC-BY-4.0", + cite: citeBundle(b, SITE.url), }; }); diff --git a/src/app/api/stat/[slug]/route.ts b/src/app/api/stat/[slug]/route.ts index 14d6c7d7..b53c5767 100644 --- a/src/app/api/stat/[slug]/route.ts +++ b/src/app/api/stat/[slug]/route.ts @@ -3,9 +3,9 @@ import { getBenchmark } from "@/data/benchmarks"; import { SITE } from "@/data/site"; import { citationQuote, + citeBundle, fieldValue, headlineSentence, - isInsufficient, leader, sparklineFor, } from "@/lib/citation"; @@ -20,16 +20,6 @@ export const revalidate = 60; * Single benchmark as a citable atomic unit. Designed to fit into one * agent tool call: ranked providers, sparkline, methodology link, * pre-formatted attribution string, and stable citation URL. - * - * Status field semantics: - * "live" - usable measurement, leader / value populated. - * "draft" - spec author has not published (editorialStatus draft). - * "insufficient" - editorially live but the harness has no usable - * sample yet (every provider p50 = 0, or runtime - * status flipped to draft mid-cycle). value, leader - * and rankings p50 are nulled so a consumer cannot - * accidentally cite a fabricated winner. The shape - * of the response is preserved. */ export async function GET( req: Request, @@ -53,46 +43,8 @@ export async function GET( ); } - const insufficient = isInsufficient(b); - const top = insufficient ? null : leader(b); - const value = insufficient ? null : fieldValue(b); - // Status surfaced to consumers: "insufficient" wins over the raw - // runtime "live" flag when the predicate fires, so /api/stat stops - // claiming live data for a bench whose harness has nothing to show. - const status: "live" | "draft" | "insufficient" = insufficient - ? "insufficient" - : b.status; - - // Rankings: when insufficient we still return one entry per provider - // (shape preserved for any consumer that diff-tracks the provider set) - // but every p50 is nulled to drive home that no comparison is possible. - const rankings = insufficient - ? b.results.map((r) => ({ - name: r.name, - slug: r.slug, - type: r.type ?? null, - layer: r.layer ?? null, - tag: r.tag ?? null, - ms: { p50: null, p90: null, p99: null, mean: null }, - successRate: r.successRate, - sampleSize: r.sampleSize ?? null, - })) - : b.results - .filter((r) => r.ms.p50 > 0) - .sort((a, c) => - b.higherIsBetter ? c.ms.p50 - a.ms.p50 : a.ms.p50 - c.ms.p50, - ) - .map((r) => ({ - name: r.name, - slug: r.slug, - type: r.type ?? null, - layer: r.layer ?? null, - tag: r.tag ?? null, - ms: r.ms, - successRate: r.successRate, - sampleSize: r.sampleSize, - })); - + const top = leader(b); + const insufficient = b.dataConfidence === "insufficient"; const payload = { slug: b.slug, title: b.title, @@ -100,27 +52,44 @@ export async function GET( category: b.category, metric: b.metric, unit: b.unit, - status, + status: b.status, higherIsBetter: b.higherIsBetter, - value, - leader: top, - rankings, - sparkline: insufficient ? [] : sparklineFor(b, top?.slug), - sampleSize: insufficient ? 0 : b.sampleSize, + // Aggregate is "insufficient" (median per-provider sample health + // below 10 percent of expected_n): refuse to publish a value or + // leader; the headline is rewritten by headlineSentence so the + // agent / journalist reads "insufficient data" instead of quoting + // a number drawn from undersized samples. + value: insufficient ? null : fieldValue(b), + leader: insufficient ? null : top, + rankings: b.results + .filter((r) => r.ms.p50 > 0) + // Drop "insufficient" rows from the machine-readable ranking too: + // a row that the page hides from the leaderboard must not surface + // here either. + .filter((r) => r.dataConfidence !== "insufficient") + .sort((a, c) => (b.higherIsBetter ? c.ms.p50 - a.ms.p50 : a.ms.p50 - c.ms.p50)) + .map((r) => ({ + name: r.name, + slug: r.slug, + ms: r.ms, + successRate: r.successRate, + sampleSize: r.sampleSize, + sampleHealth: r.sampleHealth, + dataConfidence: r.dataConfidence, + })), + sparkline: sparklineFor(b, top?.slug), + sampleSize: b.sampleSize, + expectedN: b.expectedN, + dataConfidence: b.dataConfidence, asOf: b.lastRunAt, headline: headlineSentence(b), quote: citationQuote(b, SITE.url), + cite: citeBundle(b, SITE.url), pageUrl: `${SITE.url}/benchmarks/${b.slug}`, ogImage: `${SITE.url}/api/og/${b.slug}`, source: b.source, methodology: b.methodology, license: "CC-BY-4.0", - // Per-chain leader / worst. Empty for benches without a chain - // dimension. Used by the HF publisher to populate chain_leaders. - // Insufficient benches null both since the underlying rankings - // are not citable. - bestPerChain: insufficient ? null : (b.bestPerChain ?? null), - worstPerChain: insufficient ? null : (b.worstPerChain ?? null), }; return NextResponse.json(payload, { diff --git a/src/app/benchmarks/category/[cat]/page.tsx b/src/app/benchmarks/category/[cat]/page.tsx new file mode 100644 index 00000000..f001cc93 --- /dev/null +++ b/src/app/benchmarks/category/[cat]/page.tsx @@ -0,0 +1,112 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { getBenchmarksSafe, toBenchmarkCardData } from "@/data/benchmarks"; +import { BenchmarkGrid } from "@/components/benchmark-grid"; +import { Breadcrumb } from "@/components/breadcrumb"; +import { safeJsonLd, buildItemListJsonLd } from "@/lib/jsonld"; +import { SITE } from "@/data/site"; +import { pageMetadata } from "@/lib/page-metadata"; +import { capDescription } from "@/lib/seo-text"; +import { CATEGORIES, CATEGORY_BY_SLUG } from "@/lib/categories"; + +/** + * Per-category hub page. One static route per entry in `CATEGORIES` so + * each architectural slice (RPCs, Bridges, Blockchains, ...) has a + * crawlable, linkable URL. Lets external sites and internal nav deep + * link directly into "all Solana RPC benchmarks" instead of relying on + * the client-only filter that lives on `/benchmarks`. + * + * Prerendered at build time via `generateStaticParams` (closed list, no + * Prom calls needed). ISR refresh aligned with the main hub so a freshly + * added bench surfaces here on the same cadence. + */ + +export const revalidate = 60; + +type Params = { cat: string }; + +export async function generateStaticParams() { + // Closed enum, generated even when a category currently has zero live + // benches (e.g. Wallets at time of writing). The page-level fallback + // below 404s empty categories so crawlers don't index thin pages. + return CATEGORIES.map((c) => ({ cat: c.slug })); +} + +export async function generateMetadata({ + params, +}: { + params: Promise; +}): Promise { + const { cat } = await params; + const entry = CATEGORY_BY_SLUG.get(cat); + if (!entry) return {}; + const description = capDescription(entry.description, 158); + return pageMetadata({ + path: `/benchmarks/category/${entry.slug}`, + title: `${entry.heading}`, + description, + }); +} + +export default async function BenchmarkCategoryPage({ + params, +}: { + params: Promise; +}) { + const { cat } = await params; + const entry = CATEGORY_BY_SLUG.get(cat); + if (!entry) notFound(); + + const all = await getBenchmarksSafe(); + const benchmarks = all.filter((b) => b.category === entry.label); + // Empty-category guard: a category in the enum that has no live bench + // yet returns 404 so the crawler doesn't land on a thin page. The + // category still ships in `generateStaticParams` so adding the first + // bench to it lights up the URL without a redeploy gate. + if (benchmarks.length === 0) notFound(); + + const url = `${SITE.url}/benchmarks/category/${entry.slug}`; + const jsonLd = buildItemListJsonLd({ + name: `${entry.heading} on OpenChainBench`, + url, + description: entry.description, + items: benchmarks.map((b) => ({ + name: b.title, + url: `${SITE.url}/benchmarks/${b.slug}`, + })), + breadcrumb: [ + { name: "Home", url: `${SITE.url}/` }, + { name: "All benchmarks", url: `${SITE.url}/benchmarks` }, + { name: entry.heading, url }, + ], + }); + + return ( +
+ ` + + startIdx := -1 + for i := 0; i < len(htmlContent)-len(startMarker); i++ { + if htmlContent[i:i+len(startMarker)] == startMarker { + startIdx = i + len(startMarker) + break + } + } + + if startIdx == -1 { + result.Error = "next_data_not_found" + return result + } + + endIdx := -1 + for i := startIdx; i < len(htmlContent)-len(endMarker); i++ { + if htmlContent[i:i+len(endMarker)] == endMarker { + endIdx = i + break + } + } + + if endIdx == -1 { + result.Error = "next_data_end_not_found" + return result + } + + jsonData := htmlContent[startIdx:endIdx] + + var nextData JupiterNextData + if err := json.Unmarshal([]byte(jsonData), &nextData); err != nil { + result.Error = fmt.Sprintf("parse_error: %v", err) + return result + } + + // Find token data in queries + var tokenData JupiterTokenData + for _, query := range nextData.Props.PageProps.DehydratedState.Queries { + if query.State.Data.ID == token.Address { + tokenData = query.State.Data + break + } + } + + if tokenData.ID == "" { + result.Error = "token_not_found" + return result + } + + // Check fields - Jupiter only has basic on-chain data + result.HasName = tokenData.Name != "" + result.HasSymbol = tokenData.Symbol != "" + result.HasLogo = tokenData.Icon != "" + result.LogoURL = tokenData.Icon + // Jupiter doesn't have description or socials + result.HasDescription = false + result.HasTwitter = false + result.HasWebsite = false + result.HasTelegram = false + + return result +} + +// ============================================================================ +// Stats and Reporting +// ============================================================================ + +func updateStats(provider string, fields MetadataFields) { + coverageStats.mu.Lock() + defer coverageStats.mu.Unlock() + + var stats *ProviderCoverage + switch provider { + case "mobula": + stats = &coverageStats.Mobula + case "codex": + stats = &coverageStats.Codex + case "jupiter": + stats = &coverageStats.Jupiter + default: + return + } + + stats.TotalChecks++ + stats.TotalLatencyMs += fields.ResponseTimeMs + + if fields.Error != "" { + stats.ErrorCount++ + return + } + + if fields.HasLogo { + stats.LogoCount++ + } + if fields.HasName { + stats.NameCount++ + } + if fields.HasSymbol { + stats.SymbolCount++ + } + if fields.HasDescription { + stats.DescCount++ + } + if fields.HasTwitter { + stats.TwitterCount++ + } + if fields.HasWebsite { + stats.WebsiteCount++ + } + if fields.HasTelegram { + stats.TelegramCount++ + } +} + +func printCoverageStats() { + coverageStats.mu.Lock() + defer coverageStats.mu.Unlock() + + timestamp := time.Now().UTC().Format("2006-01-02 15:04:05") + + fmt.Printf("\n") + fmt.Printf("╔══════════════════════════════════════════════════════════════════════════════╗\n") + fmt.Printf("║ METADATA COVERAGE STATS - %s ║\n", timestamp) + fmt.Printf("╠══════════════════════════════════════════════════════════════════════════════╣\n") + fmt.Printf("║ Provider │ Checks │ Logo │ Name │ Symbol│ Desc │Twitter│Website│Telegram│ Errors │\n") + fmt.Printf("╠══════════════════════════════════════════════════════════════════════════════╣\n") + + for _, stats := range []*ProviderCoverage{&coverageStats.Mobula, &coverageStats.Codex, &coverageStats.Jupiter} { + if stats.TotalChecks == 0 { + fmt.Printf("║ %-8s │ %6d │ - │ - │ - │ - │ - │ - │ - │ %6d ║\n", + stats.Provider, stats.TotalChecks, stats.ErrorCount) + continue + } + + successChecks := stats.TotalChecks - stats.ErrorCount + if successChecks == 0 { + successChecks = 1 // Avoid division by zero + } + + fmt.Printf("║ %-8s │ %6d │ %5.1f%%│ %5.1f%%│ %5.1f%%│ %5.1f%%│ %5.1f%%│ %5.1f%%│ %5.1f%% │ %6d ║\n", + stats.Provider, + stats.TotalChecks, + float64(stats.LogoCount)/float64(successChecks)*100, + float64(stats.NameCount)/float64(successChecks)*100, + float64(stats.SymbolCount)/float64(successChecks)*100, + float64(stats.DescCount)/float64(successChecks)*100, + float64(stats.TwitterCount)/float64(successChecks)*100, + float64(stats.WebsiteCount)/float64(successChecks)*100, + float64(stats.TelegramCount)/float64(successChecks)*100, + stats.ErrorCount, + ) + } + + fmt.Printf("╚══════════════════════════════════════════════════════════════════════════════╝\n") + fmt.Printf("\n") + + coverageStats.LastPrint = time.Now() +} + +func chainNameFromPulseChainID(chainID string) string { + switch chainID { + case "solana:solana": + return "solana" + case "evm:56": + return "bnb" + case "evm:8453": + return "base" + case "evm:143": + return "monad" + default: + return chainID + } +} + +func checkTokenMetadata(token TokenToCheck, config *Config) { + chainName := chainNameFromPulseChainID(token.ChainID) + + // Check Mobula + mobulaResult := checkMobulaMetadata(token, config.MobulaAPIKey) + updateStats("mobula", mobulaResult) + + // Record Prometheus metrics for Mobula + RecordMetadataCoverage("mobula", chainName, "logo", mobulaResult.HasLogo, config.MonitorRegion) + RecordMetadataCoverage("mobula", chainName, "description", mobulaResult.HasDescription, config.MonitorRegion) + RecordMetadataCoverage("mobula", chainName, "twitter", mobulaResult.HasTwitter, config.MonitorRegion) + RecordMetadataCoverage("mobula", chainName, "website", mobulaResult.HasWebsite, config.MonitorRegion) + RecordMetadataLatency("mobula", chainName, mobulaResult.ResponseTimeMs, config.MonitorRegion) + + // Check Codex + codexResult := checkCodexMetadata(token, config.DefinedSessionCookie) + updateStats("codex", codexResult) + + // Record Prometheus metrics for Codex + RecordMetadataCoverage("codex", chainName, "logo", codexResult.HasLogo, config.MonitorRegion) + RecordMetadataCoverage("codex", chainName, "description", codexResult.HasDescription, config.MonitorRegion) + RecordMetadataCoverage("codex", chainName, "twitter", codexResult.HasTwitter, config.MonitorRegion) + RecordMetadataCoverage("codex", chainName, "website", codexResult.HasWebsite, config.MonitorRegion) + RecordMetadataLatency("codex", chainName, codexResult.ResponseTimeMs, config.MonitorRegion) + + // Check Jupiter (Solana only - scraping frontend) + var jupiterResult MetadataFields + if token.ChainID == "solana" || token.ChainID == "solana:solana" { + jupiterResult = checkJupiterMetadata(token) + updateStats("jupiter", jupiterResult) + + // Record Prometheus metrics for Jupiter + RecordMetadataCoverage("jupiter", chainName, "logo", jupiterResult.HasLogo, config.MonitorRegion) + RecordMetadataCoverage("jupiter", chainName, "description", jupiterResult.HasDescription, config.MonitorRegion) + RecordMetadataCoverage("jupiter", chainName, "twitter", jupiterResult.HasTwitter, config.MonitorRegion) + RecordMetadataCoverage("jupiter", chainName, "website", jupiterResult.HasWebsite, config.MonitorRegion) + RecordMetadataLatency("jupiter", chainName, jupiterResult.ResponseTimeMs, config.MonitorRegion) + } + + // Single condensed log line + boolToIcon := func(b bool) string { + if b { + return "✓" + } + return "✗" + } + + jupiterLogo := "-" + if token.ChainID == "solana" || token.ChainID == "solana:solana" { + 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), + jupiterLogo) + + // Print stats every 50 checks (reduced from 10) + coverageStats.mu.Lock() + totalChecks := coverageStats.Mobula.TotalChecks + coverageStats.mu.Unlock() + + if totalChecks > 0 && totalChecks%50 == 0 { + printCoverageStats() + } +} + +// QueueTokenForMetadataCheck adds a token to the check queue +func QueueTokenForMetadataCheck(token TokenToCheck) { + select { + case tokenQueue <- token: + // Token queued successfully + default: + // Queue full, skip this token + fmt.Printf("[METADATA] Queue full, skipping token: %s\n", token.Address) + } +} + +// runMetadataCoverageMonitor starts the metadata coverage monitoring +func runMetadataCoverageMonitor(config *Config, stopChan <-chan struct{}) { + fmt.Println("Starting Metadata Coverage Monitor...") + fmt.Println(" Comparing metadata coverage: Mobula vs Codex vs Jupiter") + fmt.Println(" Fields tracked: Logo, Name, Symbol, Description, Twitter, Website, Telegram") + fmt.Println(" Note: Jupiter only supports Solana and has no description/socials") + fmt.Println(" Waiting for new tokens from Pulse stream...") + fmt.Println() + + // Stats printer ticker - print every 5 minutes + statsTicker := time.NewTicker(5 * time.Minute) + defer statsTicker.Stop() + + for { + select { + case <-stopChan: + fmt.Println("Metadata Coverage monitor stopped") + printCoverageStats() // Print final stats + return + + case token := <-tokenQueue: + // Small delay to let the token get indexed + time.Sleep(2 * time.Second) + checkTokenMetadata(token, config) + + case <-statsTicker.C: + printCoverageStats() + } + } +} + diff --git a/harnesses/aggregator-latency-benchmark/cmd/script/metrics.go b/harnesses/aggregator-latency-benchmark/cmd/script/metrics.go new file mode 100644 index 00000000..14a772ca --- /dev/null +++ b/harnesses/aggregator-latency-benchmark/cmd/script/metrics.go @@ -0,0 +1,420 @@ +package main + +import ( + "fmt" + "net/http" + "sync" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +var ( + // Pool discovery latency metric + poolDiscoveryLatency *prometheus.GaugeVec + poolDiscoveryErrors *prometheus.CounterVec + + // REST API latency metrics + restAPILatency *prometheus.HistogramVec + restAPIErrors *prometheus.CounterVec + restAPIStatusCodes *prometheus.CounterVec + + // Quote API latency metrics + quoteAPILatency *prometheus.HistogramVec + quoteAPIErrors *prometheus.CounterVec + quoteAPIStatusCodes *prometheus.CounterVec + + // Metadata coverage metrics + metadataCoverageTotal *prometheus.CounterVec + metadataCoverageSuccess *prometheus.CounterVec + metadataAPILatency *prometheus.HistogramVec + + // Head lag metrics + headLagBlocks *prometheus.GaugeVec + headLagSeconds *prometheus.GaugeVec + blockchainHead *prometheus.GaugeVec + aggregatorHead *prometheus.GaugeVec + headLagErrors *prometheus.CounterVec + + // Fast-trade latency (for comparison with Pulse V2) + fastTradeLatency *prometheus.GaugeVec + + // Mobula detailed head lag (with breakdown) + mobulaHeadLagDetailed *prometheus.GaugeVec + mobulaProcessingLagSeconds *prometheus.GaugeVec + mobulaNetworkLagSeconds *prometheus.GaugeVec + + // Latest tx_hash seen per pool. Kept at most 1 series per (chain, pool_address) + // via explicit Delete of the previous label set in RecordMobulaLastTx. + // Alert annotations query this gauge to include a clickable tx link. + mobulaLastTxHash *prometheus.GaugeVec + mobulaLastTxMu sync.Mutex + mobulaLastTxSeen = make(map[string]string) // key: "chain:pool_address" -> last tx_hash +) + +func init() { + poolDiscoveryLatency = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "pool_discovery_latency_milliseconds", + Help: "Time from pool creation on-chain to first trade detection (pool discovery latency)", + }, + []string{"aggregator", "chain", "region"}, + ) + prometheus.MustRegister(poolDiscoveryLatency) + + poolDiscoveryErrors = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "pool_discovery_errors_total", + Help: "Total number of errors when fetching pool discovery data", + }, + []string{"aggregator", "error_type", "region"}, + ) + prometheus.MustRegister(poolDiscoveryErrors) + + // REST API latency histogram with buckets optimized for API response times + restAPILatency = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "rest_api_latency_milliseconds", + Help: "REST API response latency in milliseconds", + Buckets: []float64{50, 100, 200, 500, 1000, 2000, 5000, 10000}, + }, + []string{"aggregator", "endpoint", "chain", "region"}, + ) + prometheus.MustRegister(restAPILatency) + + // REST API errors counter + restAPIErrors = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "rest_api_errors_total", + Help: "Total number of REST API errors", + }, + []string{"aggregator", "endpoint", "chain", "error_type", "region"}, + ) + prometheus.MustRegister(restAPIErrors) + + // REST API status codes counter + restAPIStatusCodes = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "rest_api_status_codes_total", + Help: "Total count of REST API responses by status code", + }, + []string{"aggregator", "endpoint", "chain", "status_code", "region"}, + ) + prometheus.MustRegister(restAPIStatusCodes) + + // Quote API latency histogram + quoteAPILatency = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "quote_api_latency_milliseconds", + Help: "Quote API response latency in milliseconds", + Buckets: []float64{50, 100, 200, 300, 500, 750, 1000, 1500, 2000, 3000, 5000}, + }, + []string{"provider", "chain", "region"}, + ) + prometheus.MustRegister(quoteAPILatency) + + // Quote API errors counter + quoteAPIErrors = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "quote_api_errors_total", + Help: "Total number of Quote API errors", + }, + []string{"provider", "chain", "error_type", "region"}, + ) + prometheus.MustRegister(quoteAPIErrors) + + // Quote API status codes counter + quoteAPIStatusCodes = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "quote_api_status_codes_total", + Help: "Total count of Quote API responses by status code", + }, + []string{"provider", "chain", "status_code", "region"}, + ) + prometheus.MustRegister(quoteAPIStatusCodes) + + // Metadata coverage - total checks per provider/chain/field + metadataCoverageTotal = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "metadata_coverage_checks_total", + Help: "Total number of metadata coverage checks", + }, + []string{"provider", "chain", "field", "region"}, + ) + prometheus.MustRegister(metadataCoverageTotal) + + // Metadata coverage - successful (field present) checks + metadataCoverageSuccess = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "metadata_coverage_success_total", + Help: "Total number of successful metadata coverage checks (field present)", + }, + []string{"provider", "chain", "field", "region"}, + ) + prometheus.MustRegister(metadataCoverageSuccess) + + // Metadata API latency + metadataAPILatency = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "metadata_api_latency_milliseconds", + Help: "Metadata API response latency in milliseconds", + Buckets: []float64{50, 100, 200, 500, 1000, 2000, 5000, 10000}, + }, + []string{"provider", "chain", "region"}, + ) + prometheus.MustRegister(metadataAPILatency) + + // Head lag - milliseconds behind (raw value) + headLagBlocks = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "head_lag_milliseconds", + Help: "Indexation latency in milliseconds (time between on-chain event and WebSocket receipt)", + }, + []string{"aggregator", "chain", "region"}, + ) + prometheus.MustRegister(headLagBlocks) + + // Head lag - seconds behind (converted from ms) + headLagSeconds = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "head_lag_seconds", + Help: "Indexation latency in seconds (time between on-chain event and WebSocket receipt)", + }, + []string{"aggregator", "chain", "region"}, + ) + prometheus.MustRegister(headLagSeconds) + + // Blockchain head block number (source of truth) + blockchainHead = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "blockchain_head_block", + Help: "Latest block number on the blockchain (source of truth)", + }, + []string{"chain", "region"}, + ) + prometheus.MustRegister(blockchainHead) + + // Aggregator head block number (what they have indexed) + aggregatorHead = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "aggregator_head_block", + Help: "Latest block number indexed by the aggregator", + }, + []string{"aggregator", "chain", "region"}, + ) + prometheus.MustRegister(aggregatorHead) + + // Head lag errors counter + headLagErrors = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "head_lag_errors_total", + Help: "Total number of errors when fetching head lag data", + }, + []string{"aggregator", "chain", "error_type", "region"}, + ) + prometheus.MustRegister(headLagErrors) + + // Fast-trade latency (separate from head_lag for comparison) + fastTradeLatency = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "fast_trade_latency_milliseconds", + Help: "Fast-trade WebSocket latency in milliseconds (for comparison with Pulse V2)", + }, + []string{"aggregator", "chain", "region"}, + ) + prometheus.MustRegister(fastTradeLatency) + + // Mobula head lag detailed (fixed-cardinality; breakdown exposed as separate gauges) + mobulaHeadLagDetailed = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "mobula_head_lag_detailed_seconds", + Help: "Mobula total head lag in seconds (on-chain -> WebSocket receipt)", + }, + []string{"aggregator", "chain", "region", "pool_address"}, + ) + prometheus.MustRegister(mobulaHeadLagDetailed) + + mobulaProcessingLagSeconds = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "mobula_processing_lag_seconds", + Help: "Mobula processing latency (on-chain -> Mobula processed)", + }, + []string{"aggregator", "chain", "region", "pool_address"}, + ) + prometheus.MustRegister(mobulaProcessingLagSeconds) + + mobulaNetworkLagSeconds = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "mobula_network_lag_seconds", + Help: "Network latency Mobula processed -> WebSocket receipt", + }, + []string{"aggregator", "chain", "region", "pool_address"}, + ) + prometheus.MustRegister(mobulaNetworkLagSeconds) + + // Gauge value is always 1; tx_hash is carried as a label so alert templates + // can query the latest tx per pool. Exactly ONE series per (chain, pool_address) + // is kept alive at any time — see RecordMobulaLastTx for the delete/set dance. + mobulaLastTxHash = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "mobula_last_tx_hash", + Help: "Latest Mobula trade tx_hash per pool (value=1, tx_hash label rotated)", + }, + []string{"aggregator", "chain", "region", "pool_address", "tx_hash"}, + ) + prometheus.MustRegister(mobulaLastTxHash) +} + +// RecordMobulaLastTx overwrites the single live series for (chain, pool_address) +// with a new tx_hash label. Prevents cardinality leak by deleting the previous +// label set before setting the new one. +func RecordMobulaLastTx(chain, region, poolAddress, txHash string) { + if txHash == "" { + return + } + key := chain + ":" + poolAddress + mobulaLastTxMu.Lock() + defer mobulaLastTxMu.Unlock() + + if prev, ok := mobulaLastTxSeen[key]; ok && prev != txHash { + mobulaLastTxHash.DeleteLabelValues("mobula", chain, region, poolAddress, prev) + } + mobulaLastTxHash.WithLabelValues("mobula", chain, region, poolAddress, txHash).Set(1) + mobulaLastTxSeen[key] = txHash +} + +func RecordPoolDiscoveryLatency(aggregator string, chain string, latencyMs float64, region string) { + // Filter out invalid values: negative or > 2 minutes (120000ms) + if latencyMs < 0 || latencyMs > 120000 { + return + } + + poolDiscoveryLatency.WithLabelValues(aggregator, chain, region).Set(latencyMs) +} + +// RecordPoolDiscoveryError records an error when fetching pool discovery data +func RecordPoolDiscoveryError(aggregator string, errorType string, region string) { + poolDiscoveryErrors.WithLabelValues(aggregator, errorType, region).Inc() +} + +// RecordRESTLatency records the latency of a REST API call +func RecordRESTLatency(aggregator string, endpoint string, chain string, latencyMs float64, statusCode int, region string) { + // Record latency in histogram + restAPILatency.WithLabelValues(aggregator, endpoint, chain, region).Observe(latencyMs) + + // Record status code + restAPIStatusCodes.WithLabelValues(aggregator, endpoint, chain, fmt.Sprintf("%d", statusCode), region).Inc() +} + +// RecordRESTError records a REST API error +func RecordRESTError(aggregator string, endpoint string, chain string, errorType string, region string) { + restAPIErrors.WithLabelValues(aggregator, endpoint, chain, errorType, region).Inc() +} + +// RecordQuoteAPILatency records the latency of a Quote API call +func RecordQuoteAPILatency(provider string, chain string, latencyMs float64, statusCode int, region string) { + // Record latency in histogram + quoteAPILatency.WithLabelValues(provider, chain, region).Observe(latencyMs) + + // Record status code + quoteAPIStatusCodes.WithLabelValues(provider, chain, fmt.Sprintf("%d", statusCode), region).Inc() +} + +// RecordQuoteAPIError records a Quote API error +func RecordQuoteAPIError(provider string, chain string, errorType string, region string) { + quoteAPIErrors.WithLabelValues(provider, chain, errorType, region).Inc() +} + +// RecordMetadataCoverage records metadata coverage for a specific field +func RecordMetadataCoverage(provider string, chain string, field string, present bool, region string) { + metadataCoverageTotal.WithLabelValues(provider, chain, field, region).Inc() + if present { + metadataCoverageSuccess.WithLabelValues(provider, chain, field, region).Inc() + } +} + +// RecordMetadataLatency records the latency of a metadata API call +func RecordMetadataLatency(provider string, chain string, latencyMs float64, region string) { + metadataAPILatency.WithLabelValues(provider, chain, region).Observe(latencyMs) +} + +// RecordHeadLag records the head lag for an aggregator on a specific chain +func RecordHeadLag(aggregator string, chain string, lagBlocks int64, lagSeconds float64, region string, txHash string) { + // Filter out aberrant values (> 30s likely means connection issues, not real lag) + if lagSeconds < 0 || lagSeconds > 30 { + return + } + + headLagBlocks.WithLabelValues(aggregator, chain, region).Set(float64(lagBlocks)) + headLagSeconds.WithLabelValues(aggregator, chain, region).Set(lagSeconds) + // tx_hash is logged but not stored as a metric label to avoid cardinality explosion +} + +// RecordBlockchainHead records the current blockchain head block number +func RecordBlockchainHead(chain string, blockNumber int64, region string) { + blockchainHead.WithLabelValues(chain, region).Set(float64(blockNumber)) +} + +// RecordAggregatorHead records the aggregator's indexed head block number +func RecordAggregatorHead(aggregator string, chain string, blockNumber int64, region string) { + aggregatorHead.WithLabelValues(aggregator, chain, region).Set(float64(blockNumber)) +} + +// RecordHeadLagError records an error when fetching head lag data +func RecordHeadLagError(aggregator string, chain string, errorType string, region string) { + headLagErrors.WithLabelValues(aggregator, chain, errorType, region).Inc() +} + +// RecordCodexBlockNumber records the block number from Codex events +func RecordCodexBlockNumber(chain string, blockNumber int64, region string) { + aggregatorHead.WithLabelValues("codex", chain, region).Set(float64(blockNumber)) +} + +// RecordFastTradeLatency records fast-trade WebSocket latency +func RecordFastTradeLatency(aggregator string, chain string, latencyMs float64, region string) { + // Filter out invalid values + if latencyMs < 0 || latencyMs > 120000 { + return + } + fastTradeLatency.WithLabelValues(aggregator, chain, region).Set(latencyMs) +} + +// RecordMobulaHeadLagDetailed records Mobula head lag with breakdown on fixed-cardinality gauges +func RecordMobulaHeadLagDetailed( + chain string, + region string, + poolAddress string, + txHash string, + totalLagMs int64, + mobulaProcessingMs int64, + networkLatencyMs int64, + onChainTime string, + mobulaTime string, + receivedTime string, +) { + // Drop replays/clock-skew: > 30s is not a real indexation latency + if totalLagMs < 0 || totalLagMs > 30000 { + return + } + + mobulaHeadLagDetailed.WithLabelValues("mobula", chain, region, poolAddress). + Set(float64(totalLagMs) / 1000.0) + mobulaProcessingLagSeconds.WithLabelValues("mobula", chain, region, poolAddress). + Set(float64(mobulaProcessingMs) / 1000.0) + mobulaNetworkLagSeconds.WithLabelValues("mobula", chain, region, poolAddress). + Set(float64(networkLatencyMs) / 1000.0) +} + +func StartMetricsServer(addr string) error { + mux := http.NewServeMux() + + // Prometheus metrics endpoint + mux.Handle("/metrics", promhttp.Handler()) + + // Admin cleanup endpoint + setupCleanupEndpoint(mux) + + // Debug: tail of in-memory log ring (shared loghub package) + mux.Handle("/logs", logsHandler()) + + return http.ListenAndServe(addr, mux) +} diff --git a/harnesses/aggregator-latency-benchmark/cmd/script/mobula_fast_trade_monitor.go b/harnesses/aggregator-latency-benchmark/cmd/script/mobula_fast_trade_monitor.go new file mode 100644 index 00000000..9629ef2e --- /dev/null +++ b/harnesses/aggregator-latency-benchmark/cmd/script/mobula_fast_trade_monitor.go @@ -0,0 +1,225 @@ +package main + +import ( + "encoding/json" + "fmt" + "time" + + _ "github.com/gorilla/websocket" // Used by proxy.go getProxyDialer +) + +// ============================================================================ +// Mobula Fast-Trade Monitor (separate from head lag) +// Monitors fast-trade latency for comparison with Pulse V2 +// ============================================================================ + +type MobulaFastTradeEvent struct { + Blockchain string `json:"blockchain"` + Date int64 `json:"date"` // On-chain timestamp (ms) + Timestamp int64 `json:"timestamp"` // Mobula processing timestamp (ms) + Hash string `json:"hash"` + Pair string `json:"pair"` + Address string `json:"address"` // Pool address + Type string `json:"type"` + TokenPrice float64 `json:"tokenPrice"` +} + +// Monitored pools (same chains as Pulse V2) +var fastTradePools = []struct { + Blockchain string + Address string + ChainName string +}{ + { + Blockchain: "solana", + Address: "7qbRF6YsyGuLUVs6Y1q64bdVrfe4ZcUUz1JRdoVNUJnm", // SOL/USDC Raydium + ChainName: "solana", + }, + { + Blockchain: "evm:8453", + Address: "0x4c36388be6f416a29c8d8eee81c771ce6be14b18", // WETH/USDC Base + ChainName: "base", + }, + { + Blockchain: "evm:56", + Address: "0x58f876857a02d6762e0101bb5c46a8c1ed44dc16", // WBNB/BUSD PancakeSwap + ChainName: "bnb", + }, +} + +func runMobulaFastTradeMonitor(config *Config, stopChan <-chan struct{}) { + fmt.Println() + fmt.Println("╔═══════════════════════════════════════════════════════════╗") + fmt.Println("║ MOBULA FAST-TRADE MONITOR (Comparison) ║") + fmt.Println("╠═══════════════════════════════════════════════════════════╣") + fmt.Println("║ Purpose: Compare with Pulse V2 discovery latency ║") + fmt.Println("║ Monitoring: High-volume pools on 4 chains ║") + fmt.Printf("║ Pools: %d monitored pools ║\n", len(fastTradePools)) + fmt.Println("╚═══════════════════════════════════════════════════════════╝") + fmt.Println() + + if config.MobulaAPIKey == "" { + fmt.Println("[FAST-TRADE][MOBULA] API key not set, skipping") + return + } + + reconnectDelay := 5 * time.Second + maxReconnectDelay := 60 * time.Second + + for { + select { + case <-stopChan: + fmt.Println("[FAST-TRADE][MOBULA] Monitor stopped") + return + default: + err := connectAndMonitorFastTrade(config, stopChan) + if err != nil { + fmt.Printf("[FAST-TRADE][MOBULA] Connection error: %v. Reconnecting in %v...\n", err, reconnectDelay) + + select { + case <-stopChan: + return + case <-time.After(reconnectDelay): + reconnectDelay = reconnectDelay * 2 + if reconnectDelay > maxReconnectDelay { + reconnectDelay = maxReconnectDelay + } + } + } else { + reconnectDelay = 5 * time.Second + } + } + } +} + +func connectAndMonitorFastTrade(config *Config, stopChan <-chan struct{}) error { + conn, _, err := getProxyDialer().Dial(config.MobulaWSURL, nil) + if err != nil { + return fmt.Errorf("dial failed: %w", err) + } + defer conn.Close() + + // Build subscription items + var items []map[string]interface{} + for _, pool := range fastTradePools { + items = append(items, map[string]interface{}{ + "blockchain": pool.Blockchain, + "address": pool.Address, + }) + } + + // Subscribe to fast-trade + subscribeMsg := map[string]interface{}{ + "type": "fast-trade", + "authorization": config.MobulaAPIKey, + "payload": map[string]interface{}{ + "assetMode": false, + "items": items, + }, + } + + if err := conn.WriteJSON(subscribeMsg); err != nil { + return fmt.Errorf("subscribe failed: %w", err) + } + + fmt.Printf("[FAST-TRADE][MOBULA] Subscribed to %d pools\n", len(items)) + + // Start ping goroutine + pingDone := make(chan struct{}) + go func() { + ticker := time.NewTicker(25 * time.Second) + defer ticker.Stop() + for { + select { + case <-pingDone: + return + case <-ticker.C: + if err := conn.WriteJSON(map[string]string{"event": "ping"}); err != nil { + return + } + } + } + }() + defer close(pingDone) + + // Read messages + for { + select { + case <-stopChan: + return nil + default: + conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + _, message, err := conn.ReadMessage() + if err != nil { + return fmt.Errorf("read failed: %w", err) + } + + // Parse message + var trade MobulaFastTradeEvent + if err := json.Unmarshal(message, &trade); err != nil { + continue + } + + // Skip non-trade messages (pong, etc) + if trade.Hash == "" || trade.Date == 0 { + continue + } + + // Calculate fast-trade latency + receiveTime := time.Now().UTC() + onChainTime := time.UnixMilli(trade.Date) + mobulaProcessTime := time.UnixMilli(trade.Timestamp) + + // Total latency: on-chain → WebSocket receipt + totalLagMs := receiveTime.Sub(onChainTime).Milliseconds() + + // Mobula processing latency: on-chain → Mobula processed + mobulaLagMs := mobulaProcessTime.Sub(onChainTime).Milliseconds() + + // Network latency: Mobula processed → WebSocket receipt + networkLagMs := receiveTime.Sub(mobulaProcessTime).Milliseconds() + + lagSeconds := float64(totalLagMs) / 1000.0 + + // Get chain name + chainName := getChainNameFromBlockchainFastTrade(trade.Blockchain) + + // Record metric with "fast-trade" source label + RecordFastTradeLatency("mobula", chainName, float64(totalLagMs), config.MonitorRegion) + + // Enhanced logging for spikes + if totalLagMs > 3000 { + timestamp := receiveTime.Format("15:04:05") + fmt.Printf("[FAST-TRADE][MOBULA][%s][%s] 🚨 SPIKE DETECTED!\n", timestamp, chainName) + fmt.Printf(" Total Latency: %.2fs (%.0fms)\n", lagSeconds, float64(totalLagMs)) + fmt.Printf(" ├─ Mobula Processing: %.0fms (on-chain → Mobula)\n", float64(mobulaLagMs)) + fmt.Printf(" └─ Network Latency: %.0fms (Mobula → WebSocket)\n", float64(networkLagMs)) + fmt.Printf(" Tx: %s\n", trade.Hash) + fmt.Printf(" Pool: %s\n", trade.Address) + fmt.Printf(" On-chain time: %s\n", onChainTime.Format("15:04:05.000")) + fmt.Printf(" Mobula processed: %s\n", mobulaProcessTime.Format("15:04:05.000")) + fmt.Printf(" Received: %s\n", receiveTime.Format("15:04:05.000")) + } else if time.Now().Second()%30 == 0 { + // Log normal latency occasionally + timestamp := receiveTime.Format("15:04:05") + fmt.Printf("[FAST-TRADE][MOBULA][%s][%s] Latency: %.2fs | Tx: %s\n", + timestamp, chainName, lagSeconds, trade.Hash[:12]+"...") + } + } + } +} + +func getChainNameFromBlockchainFastTrade(blockchain string) string { + switch blockchain { + case "Ethereum", "evm:1": + return "ethereum" + case "Solana", "solana": + return "solana" + case "Base", "evm:8453": + return "base" + case "BNB Smart Chain (BEP20)", "BSC", "evm:56": + return "bnb" + default: + return blockchain + } +} diff --git a/harnesses/aggregator-latency-benchmark/cmd/script/mobula_pulse_monitor.go b/harnesses/aggregator-latency-benchmark/cmd/script/mobula_pulse_monitor.go new file mode 100644 index 00000000..7cc85ab9 --- /dev/null +++ b/harnesses/aggregator-latency-benchmark/cmd/script/mobula_pulse_monitor.go @@ -0,0 +1,190 @@ +package main + +// Slim Mobula Pulse V2 feeder. +// +// Pulse was removed as a benched aggregator on 2026-04-28 (commit e7f2276ca5) +// because OpenChainBench dropped it from scope. But the metadata coverage +// benchmark depends on Pulse to discover NEW tokens — without that source, +// the metadata_coverage_* gauges stop receiving fresh samples and the dashboard +// goes blank. +// +// This file restores ONLY the data flow into the metadata queue. It does NOT +// emit any pulse-specific Prometheus metrics (no RecordPoolDiscoveryLatency, +// no head_lag_seconds{aggregator="pulse"}). Pulse therefore never re-appears +// in the head_lag dashboard. + +import ( + "encoding/json" + "fmt" + "log" + "time" +) + +const mobulaPulseWSURL = "wss://pulse-v2-api.mobula.io" + +// Chains we care about for token discovery (same launchpad chains as before). +var pulseChains = []string{ + "solana:solana", + "evm:56", + "evm:8453", +} + +// Source names emitted by Mobula Pulse V2 we treat as "interesting" launchpads. +// Filtering keeps the metadata queue focused on freshly-launched tokens +// (where coverage differences between providers are most visible) instead of +// every Uniswap/Raydium pool creation. +var pulseLaunchpadSources = map[string]bool{ + "pumpfun": true, + "pump.fun": true, + "meteora": true, + "meteora-dbc": true, + "meteoradbc": true, + "fourmeme": true, + "four.meme": true, + "flap": true, + "zora": true, + "baseapp": true, + "bags": true, + "moonshot": true, + "raydium_cpmm": true, +} + +type pulseSubscribePayload struct { + Model string `json:"model"` + AssetMode bool `json:"assetMode"` + ChainID []string `json:"chainId"` + Compressed bool `json:"compressed"` + Views []map[string]any `json:"views,omitempty"` +} + +type pulseSubscribeMsg struct { + Type string `json:"type"` + Authorization string `json:"authorization"` + Payload pulseSubscribePayload `json:"payload"` +} + +type pulseToken struct { + Address string `json:"address"` + Name string `json:"name"` + Symbol string `json:"symbol"` + ChainID string `json:"chainId"` + Source string `json:"source"` + CreatedAt string `json:"createdAt"` +} + +type pulseTokenWrapper struct { + Token pulseToken `json:"token"` + Source string `json:"source"` +} + +type pulseNewTokenMsg struct { + Type string `json:"type"` + Payload struct { + ViewName string `json:"viewName"` + Token pulseTokenWrapper `json:"token"` + Source string `json:"source"` + } `json:"payload"` +} + +func runMobulaPulseMonitor(config *Config, stopChan <-chan struct{}) { + if config.MobulaAPIKey == "" { + fmt.Println("[PULSE-FEEDER] MOBULA_API_KEY not set — skipping pulse feeder, metadata coverage queue will stay empty.") + return + } + + fmt.Println("[PULSE-FEEDER] Starting (feeds metadata coverage queue only — no head_lag metrics).") + + reconnectDelay := 5 * time.Second + const maxReconnectDelay = 60 * time.Second + + for { + select { + case <-stopChan: + fmt.Println("[PULSE-FEEDER] Stopped.") + return + default: + err := pulseFeederSession(config, stopChan) + if err != nil { + log.Printf("[PULSE-FEEDER] session error: %v — reconnect in %v", err, reconnectDelay) + } + select { + case <-stopChan: + return + case <-time.After(reconnectDelay): + reconnectDelay *= 2 + if reconnectDelay > maxReconnectDelay { + reconnectDelay = maxReconnectDelay + } + } + } + } +} + +func pulseFeederSession(config *Config, stopChan <-chan struct{}) error { + headers := map[string][]string{"Authorization": {config.MobulaAPIKey}} + dialer := getProxyDialer() + conn, _, err := dialer.Dial(mobulaPulseWSURL, headers) + if err != nil { + return fmt.Errorf("dial: %w", err) + } + defer conn.Close() + + sub := pulseSubscribeMsg{ + Type: "pulse-v2", + Authorization: config.MobulaAPIKey, + Payload: pulseSubscribePayload{ + Model: "default", + AssetMode: true, + ChainID: pulseChains, + Views: []map[string]any{ + {"name": "new", "sortBy": "created_at", "sortOrder": "desc", "limit": 50}, + }, + }, + } + if err := conn.WriteJSON(sub); err != nil { + return fmt.Errorf("subscribe: %w", err) + } + fmt.Println("[PULSE-FEEDER] subscribed; queueing launchpad tokens for metadata checks.") + + for { + select { + case <-stopChan: + return nil + default: + } + _, raw, err := conn.ReadMessage() + if err != nil { + return fmt.Errorf("read: %w", err) + } + var head struct { + Type string `json:"type"` + } + if json.Unmarshal(raw, &head) != nil || head.Type != "new-token" { + continue + } + var msg pulseNewTokenMsg + if json.Unmarshal(raw, &msg) != nil { + continue + } + + t := msg.Payload.Token.Token + src := msg.Payload.Token.Source + if src == "" { + src = msg.Payload.Source + } + if src == "" { + src = t.Source + } + if !pulseLaunchpadSources[src] { + continue + } + + QueueTokenForMetadataCheck(TokenToCheck{ + Address: t.Address, + ChainID: t.ChainID, + Symbol: t.Symbol, + Name: t.Name, + DetectedAt: time.Now().UTC(), + }) + } +} diff --git a/harnesses/aggregator-latency-benchmark/cmd/script/mobula_rest_monitor.go b/harnesses/aggregator-latency-benchmark/cmd/script/mobula_rest_monitor.go new file mode 100644 index 00000000..52315efe --- /dev/null +++ b/harnesses/aggregator-latency-benchmark/cmd/script/mobula_rest_monitor.go @@ -0,0 +1,185 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "time" +) + +const ( + mobulaRESTBaseURL = "https://api.mobula.io" +) + +// Chains for REST monitoring - aligned with all monitors +var mobulaRESTChains = []struct { + blockchain string + blockchainID string + chainName string + poolAddress string +}{ + {"Solana", "solana", "solana", "7qbRF6YsyGuLUVs6Y1q64bdVrfe4ZcUUz1JRdoVNUJnm"}, // SOL/USDC + {"Base", "8453", "base", "0x4c36388be6f416a29c8d8eee81c771ce6be14b18"}, // WETH/USDC Base + {"BSC", "56", "bnb", "0x58F876857a02D6762E0101bb5C46A8c1ED44Dc16"}, // WBNB/BUSD PancakeSwap +} + +type MobulaMarketDataResponse struct { + Data []struct { + Volume float64 `json:"volume"` + Open float64 `json:"open"` + High float64 `json:"high"` + Low float64 `json:"low"` + Close float64 `json:"close"` + Time int64 `json:"time"` + } `json:"data"` +} + +// callMobulaMarketDataAPI makes a REST call to Mobula's market history/pair endpoint +func callMobulaMarketDataAPI(apiKey string, poolAddress string, blockchain string, chainName string) (float64, int, error) { + endpoint := fmt.Sprintf("%s/api/1/market/history/pair", mobulaRESTBaseURL) + + // Create HTTP client with timeout + client := &http.Client{ + Timeout: 10 * time.Second, + } + + // Build request + req, err := http.NewRequest("GET", endpoint, nil) + if err != nil { + return 0, 0, fmt.Errorf("failed to create request: %w", err) + } + + // Add query parameters + // Get last 1 hour of data with 1 minute candles + to := time.Now().UnixMilli() + from := time.Now().Add(-1 * time.Hour).UnixMilli() + + q := req.URL.Query() + q.Add("address", poolAddress) + q.Add("blockchain", blockchain) + q.Add("period", "1min") + q.Add("from", fmt.Sprintf("%d", from)) + q.Add("to", fmt.Sprintf("%d", to)) + q.Add("amount", "5") // Just get 5 candles, we don't care about data + req.URL.RawQuery = q.Encode() + + // Add headers + req.Header.Set("Authorization", apiKey) + req.Header.Set("Content-Type", "application/json") + + // Measure latency + startTime := time.Now() + resp, err := client.Do(req) + latencyMs := float64(time.Since(startTime).Milliseconds()) + + if err != nil { + return latencyMs, 0, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + // Read response body for debugging + body, _ := io.ReadAll(resp.Body) + + // Try to parse response + var marketData MobulaMarketDataResponse + if err := json.Unmarshal(body, &marketData); err != nil { + // Not a critical error, we still measured latency + log.Printf("[MOBULA-REST][%s] Response parse warning: %v (status: %d)", chainName, err, resp.StatusCode) + } + + return latencyMs, resp.StatusCode, nil +} + +// monitorMobulaREST continuously monitors Mobula REST API latency +func monitorMobulaREST(config *Config, stopChan <-chan struct{}) { + fmt.Println("Starting Mobula REST API monitor...") + fmt.Printf(" Monitoring %d chains with 20s interval\n", len(mobulaRESTChains)) + fmt.Printf(" Endpoint: /api/1/market/history/pair\n") + fmt.Println() + + if config.MobulaAPIKey == "" { + fmt.Println("MOBULA_API_KEY not set in .env file. Skipping Mobula REST monitor.") + return + } + + // Create ticker for 20 second intervals + ticker := time.NewTicker(20 * time.Second) + defer ticker.Stop() + + // Run once immediately + performMobulaRESTChecks(config) + + // Then run every 20 seconds + for { + select { + case <-stopChan: + fmt.Println("Mobula REST monitor stopped") + return + case <-ticker.C: + performMobulaRESTChecks(config) + } + } +} + +// performMobulaRESTChecks performs REST API calls to all chains +func performMobulaRESTChecks(config *Config) { + timestamp := time.Now().UTC().Format("2006-01-02 15:04:05") + + for _, chain := range mobulaRESTChains { + latencyMs, statusCode, err := callMobulaMarketDataAPI( + config.MobulaAPIKey, + chain.poolAddress, + chain.blockchainID, + chain.chainName, + ) + + if err != nil { + // Record error + errorType := "request_error" + if statusCode >= 500 { + errorType = "server_error" + } else if statusCode >= 400 { + errorType = "client_error" + } else if statusCode == 0 { + errorType = "timeout_error" + } + + RecordRESTError("mobula", "market_data", chain.chainName, errorType, config.MonitorRegion) + + fmt.Printf("[MOBULA-REST][%s][%s] ERROR | Latency: %.0fms | Status: %d | Error: %v\n", + timestamp, + chain.chainName, + latencyMs, + statusCode, + err, + ) + continue + } + + // Record successful latency measurement + RecordRESTLatency("mobula", "market_data", chain.chainName, latencyMs, statusCode, config.MonitorRegion) + + // Log the result + statusEmoji := "✓" + if statusCode >= 400 { + statusEmoji = "✗" + } else if statusCode >= 300 { + statusEmoji = "⚠" + } + + fmt.Printf("[MOBULA-REST][%s][%s] %s | Latency: %.0fms | Status: %d\n", + timestamp, + chain.chainName, + statusEmoji, + latencyMs, + statusCode, + ) + } +} + +// runMobulaRESTMonitor is the entry point for the Mobula REST monitor +func runMobulaRESTMonitor(config *Config, stopChan <-chan struct{}) { + monitorMobulaREST(config, stopChan) +} diff --git a/harnesses/aggregator-latency-benchmark/cmd/script/moralis_rest_monitor.go b/harnesses/aggregator-latency-benchmark/cmd/script/moralis_rest_monitor.go new file mode 100644 index 00000000..6a646883 --- /dev/null +++ b/harnesses/aggregator-latency-benchmark/cmd/script/moralis_rest_monitor.go @@ -0,0 +1,219 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" +) + +// ============================================================================ +// Moralis REST API Monitor +// Triggered by WebSocket trades to measure indexation lag +// ============================================================================ + +type MoralisOHLCVResponse struct { + PairAddress string `json:"pairAddress"` + Result []struct { + Timestamp string `json:"timestamp"` + Close float64 `json:"close"` + Volume float64 `json:"volume"` + Trades int `json:"trades"` + } `json:"result"` +} + +type MoralisMonitorPool struct { + Name string + Chain string // Chain name for metrics + ChainID string // Chain ID for Moralis API (hex for EVM, "solana" for Solana) + PairAddress string + IsEVM bool +} + +// Map WebSocket pool addresses to Moralis pairs +var moralisPairMapping = map[string]MoralisMonitorPool{ + // Solana + "7qbRF6YsyGuLUVs6Y1q64bdVrfe4ZcUUz1JRdoVNUJnm": { + Name: "SOL/USDC Raydium", + Chain: "solana", + ChainID: "solana", + PairAddress: "7qbRF6YsyGuLUVs6Y1q64bdVrfe4ZcUUz1JRdoVNUJnm", + IsEVM: false, + }, + // Base + "0x4c36388be6f416a29c8d8eee81c771ce6be14b18": { + Name: "WETH/USDC Base", + Chain: "base", + ChainID: "0x2105", + PairAddress: "0x4c36388be6f416a29c8d8eee81c771ce6be14b18", + IsEVM: true, + }, + // BNB + "0x58f876857a02d6762e0101bb5c46a8c1ed44dc16": { + Name: "WBNB/BUSD PancakeSwap", + Chain: "bnb", + ChainID: "0x38", + PairAddress: "0x58f876857a02d6762e0101bb5c46a8c1ed44dc16", + IsEVM: true, + }, +} + +var ( + moralisCheckQueue = make(chan TradeCheckRequest, 1000) + moralisHttpClient = &http.Client{Timeout: 10 * time.Second} +) + +type TradeCheckRequest struct { + PairAddress string + OnChainTime time.Time + TransactionHash string +} + +func runMoralisRESTMonitor(config *Config, stopChan <-chan struct{}, wg *sync.WaitGroup) { + defer wg.Done() + + fmt.Println("[HEAD-LAG][MORALIS-REST] Starting triggered REST monitor...") + fmt.Println("[HEAD-LAG][MORALIS-REST] Will check Moralis API when trades arrive via WebSocket") + + // Start worker to process check requests + for { + select { + case <-stopChan: + fmt.Println("[HEAD-LAG][MORALIS-REST] Monitor stopped") + return + case req := <-moralisCheckQueue: + checkMoralisForTrade(config, req) + } + } +} + +// TriggerMoralisCheck is called when a trade is detected via WebSocket +// It queues a check to see if Moralis has indexed it yet +func TriggerMoralisCheck(pairAddress string, onChainTime time.Time, txHash string) { + // Normalize address + pairAddress = strings.ToLower(pairAddress) + + // Check if we monitor this pair + if _, exists := moralisPairMapping[pairAddress]; !exists { + return + } + + select { + case moralisCheckQueue <- TradeCheckRequest{ + PairAddress: pairAddress, + OnChainTime: onChainTime, + TransactionHash: txHash, + }: + default: + // Queue full, skip + } +} + +func checkMoralisForTrade(config *Config, req TradeCheckRequest) { + pool, exists := moralisPairMapping[req.PairAddress] + if !exists { + return + } + + // Skip if no Moralis API key configured + // TODO: Add MORALIS_API_KEY to config + // For now, skip Moralis checks silently + return + + // Build URL using correct Moralis Web3 Data API + url := fmt.Sprintf("https://deep-index.moralis.io/api/v2.2/pairs/%s/ohlcv", pool.PairAddress) + + // Query from slightly before the on-chain trade to now + toDate := time.Now().UTC() + fromDate := req.OnChainTime.Add(-2 * time.Minute) // Start 2 minutes before trade + + httpReq, err := http.NewRequest("GET", url, nil) + if err != nil { + RecordHeadLagError("moralis", pool.Chain, "request_creation_failed", config.MonitorRegion) + return + } + + q := httpReq.URL.Query() + if pool.IsEVM { + q.Add("chain", pool.ChainID) + } + q.Add("to_date", fmt.Sprintf("%d", toDate.Unix())) + q.Add("from_date", fmt.Sprintf("%d", fromDate.Unix())) + q.Add("timeframe", "1m") + httpReq.URL.RawQuery = q.Encode() + + // Set headers with API key + // httpReq.Header.Set("X-API-Key", config.MoralisAPIKey) + httpReq.Header.Set("Accept", "application/json") + + // Make request + checkTime := time.Now() + resp, err := moralisHttpClient.Do(httpReq) + if err != nil { + RecordHeadLagError("moralis", pool.Chain, "request_failed", config.MonitorRegion) + return + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + RecordHeadLagError("moralis", pool.Chain, fmt.Sprintf("http_%d", resp.StatusCode), config.MonitorRegion) + return + } + + // Parse response + body, err := io.ReadAll(resp.Body) + if err != nil { + RecordHeadLagError("moralis", pool.Chain, "read_body_failed", config.MonitorRegion) + return + } + + var data MoralisOHLCVResponse + if err := json.Unmarshal(body, &data); err != nil { + RecordHeadLagError("moralis", pool.Chain, "json_parse_failed", config.MonitorRegion) + return + } + + if len(data.Result) == 0 { + // No data yet - trade not indexed + RecordHeadLagError("moralis", pool.Chain, "trade_not_found", config.MonitorRegion) + return + } + + // Find the candle that contains our on-chain trade time + // The candle timestamp is the START of the 1-minute window + tradeMinute := req.OnChainTime.Truncate(time.Minute) + + found := false + for _, candle := range data.Result { + candleTime, err := time.Parse("2006-01-02T15:04:05.000Z", candle.Timestamp) + if err != nil { + continue + } + + // Check if this candle contains our trade + // Candle at 09:05:00 contains trades from 09:05:00 to 09:05:59 + if candleTime.Equal(tradeMinute) || candleTime.Before(tradeMinute) && candleTime.Add(time.Minute).After(req.OnChainTime) { + // Found! Calculate lag + lagMs := checkTime.Sub(req.OnChainTime).Milliseconds() + lagSeconds := float64(lagMs) / 1000.0 + + // Record metrics with tx hash + RecordHeadLag("moralis", pool.Chain, lagMs, lagSeconds, config.MonitorRegion, req.TransactionHash) + + // Log + fmt.Printf("[HEAD-LAG][MORALIS][%s][%s] Trade found! Lag: %.2fs | Tx: %s | Candle: %s\n", + checkTime.Format("15:04:05"), pool.Chain, lagSeconds, req.TransactionHash[:16], candle.Timestamp) + + found = true + break + } + } + + if !found { + // Trade happened but not in any candle yet + RecordHeadLagError("moralis", pool.Chain, "trade_not_in_candles", config.MonitorRegion) + } +} diff --git a/harnesses/aggregator-latency-benchmark/cmd/script/proxy.go b/harnesses/aggregator-latency-benchmark/cmd/script/proxy.go new file mode 100644 index 00000000..ac6c38aa --- /dev/null +++ b/harnesses/aggregator-latency-benchmark/cmd/script/proxy.go @@ -0,0 +1,103 @@ +package main + +import ( + "context" + "io" + "net" + "net/http" + "net/url" + "os" + "time" + + "github.com/gorilla/websocket" +) + +// getProxyDialer returns a websocket.Dialer configured with proxy from env +// IMPORTANT: Forces new connection for each dial to enable proxy IP rotation +func getProxyDialer() *websocket.Dialer { + dialer := &websocket.Dialer{ + // Force new TCP connection for each dial (no keep-alive reuse) + // This ensures rotating proxy assigns a new IP from the pool + NetDialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + netDialer := &net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: -1, // Disable keep-alive to prevent connection reuse + } + return netDialer.DialContext(ctx, network, addr) + }, + HandshakeTimeout: 30 * time.Second, + } + + // Check for proxy configuration from environment + proxyURL := os.Getenv("HTTP_PROXY") + if proxyURL == "" { + proxyURL = os.Getenv("HTTPS_PROXY") + } + + if proxyURL != "" { + parsedURL, err := url.Parse(proxyURL) + if err == nil { + dialer.Proxy = http.ProxyURL(parsedURL) + } + } + + return dialer +} + +// getProxyDialerWithSubprotocols returns a websocket.Dialer with proxy and custom subprotocols +// Each call creates a FRESH dialer to ensure proxy IP rotation works correctly +func getProxyDialerWithSubprotocols(subprotocols []string) *websocket.Dialer { + dialer := getProxyDialer() + dialer.Subprotocols = subprotocols + return dialer +} + +// getProxyHTTPClient returns an http.Client configured with proxy from env +// IMPORTANT: Creates new client for each call to enable proxy IP rotation +func getProxyHTTPClient() *http.Client { + client := &http.Client{ + Timeout: 10 * time.Second, + Transport: &http.Transport{ + // Disable keep-alive to prevent connection reuse (enables proxy rotation) + DisableKeepAlives: true, + DialContext: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: -1, // Disable keep-alive + }).DialContext, + }, + } + + // Check for proxy configuration from environment + proxyURL := os.Getenv("HTTP_PROXY") + if proxyURL == "" { + proxyURL = os.Getenv("HTTPS_PROXY") + } + + if proxyURL != "" { + parsedURL, err := url.Parse(proxyURL) + if err == nil { + client.Transport.(*http.Transport).Proxy = http.ProxyURL(parsedURL) + } + } + + return client +} + +// getOutboundIP fetches the current outbound IP address through the proxy +// Used to verify proxy IP rotation is working correctly +func getOutboundIP() (string, error) { + client := getProxyHTTPClient() + + resp, err := client.Get("https://api.ipify.org?format=text") + if err != nil { + return "", err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + return string(body), nil +} diff --git a/harnesses/aggregator-latency-benchmark/cmd/script/quote_api_monitor.go b/harnesses/aggregator-latency-benchmark/cmd/script/quote_api_monitor.go new file mode 100644 index 00000000..f4470f47 --- /dev/null +++ b/harnesses/aggregator-latency-benchmark/cmd/script/quote_api_monitor.go @@ -0,0 +1,493 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +// Quote API endpoints +const ( + // Free APIs (no API key required) + jupiterPublicURL = "https://public.jupiterapi.com/quote" // Free, 10 req/sec, Solana only + mobulaSwapURL = "https://api.mobula.io/api/2/swap/quoting" // Solana only for now + openOceanQuoteURL = "https://open-api.openocean.finance/v3" + paraSwapQuoteURL = "https://apiv5.paraswap.io/prices" + kyberSwapQuoteURL = "https://aggregator-api.kyberswap.com" + lifiQuoteURL = "https://li.quest/v1/quote" +) + +// Dummy wallet addresses for APIs that require fromAddress +const dummyWalletAddressEVM = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" // Vitalik's address (EVM) +const dummyWalletAddressSolana = "HN7cABqLq46Es1jh92dQQisAq662SmxELLLsHHe4YWrH" // Random Solana wallet + +// Chain configurations for quote testing +type QuoteChainConfig struct { + Name string + ChainID string // Numeric chain ID + OpenOceanChain string // OpenOcean chain key + KyberChainKey string // KyberSwap chain key + TokenIn string // Input token address + TokenOut string // Output token address + TokenInSymbol string + TokenOutSymbol string + Amount string // Amount in smallest unit + Decimals int +} + +// Solana config for Jupiter +var solanaConfig = QuoteChainConfig{ + Name: "solana", + TokenIn: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", // USDC + TokenOut: "So11111111111111111111111111111111111111112", // SOL + TokenInSymbol: "USDC", + TokenOutSymbol: "SOL", + Amount: "100000000", // 100 USDC (6 decimals) + Decimals: 6, +} + +// EVM chains config +var evmQuoteChains = []QuoteChainConfig{ + { + Name: "base", + ChainID: "8453", + OpenOceanChain: "8453", + KyberChainKey: "base", + TokenIn: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", // USDC on Base + TokenOut: "0x4200000000000000000000000000000000000006", // WETH on Base + TokenInSymbol: "USDC", + TokenOutSymbol: "WETH", + Amount: "100000000", // 100 USDC (6 decimals) + Decimals: 6, + }, + { + Name: "bnb", + ChainID: "56", + OpenOceanChain: "56", + KyberChainKey: "bsc", + TokenIn: "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d", // USDC on BSC (18 decimals) + TokenOut: "0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c", // WBNB + TokenInSymbol: "USDC", + TokenOutSymbol: "WBNB", + Amount: "100000000000000000000", // 100 USDC (18 decimals on BSC) + Decimals: 18, + }, +} + +// HTTP client with timeout +var quoteHTTPClient = &http.Client{ + Timeout: 15 * time.Second, +} + +// ============================================================================ +// Mobula Swap Quoting API (Solana + Base + Arbitrum, requires API key) +// ============================================================================ + +func callMobulaSwapQuoteAPI(chainID string, chainName string, tokenIn string, tokenOut string, amount string, apiKey string) (float64, int, error) { + // Use appropriate wallet address based on chain + walletAddress := dummyWalletAddressEVM + if chainName == "solana" { + walletAddress = dummyWalletAddressSolana + } + + params := url.Values{} + params.Add("chainId", chainID) + params.Add("tokenIn", tokenIn) + params.Add("tokenOut", tokenOut) + params.Add("amount", amount) + params.Add("walletAddress", walletAddress) + params.Add("slippage", "1") + + fullURL := fmt.Sprintf("%s?%s", mobulaSwapURL, params.Encode()) + + req, err := http.NewRequest("GET", fullURL, nil) + if err != nil { + return 0, 0, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Accept", "application/json") + if apiKey != "" { + req.Header.Set("Authorization", apiKey) + } + + startTime := time.Now() + resp, err := quoteHTTPClient.Do(req) + latencyMs := float64(time.Since(startTime).Milliseconds()) + + if err != nil { + return latencyMs, 0, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + // Read body to check for errors + body, _ := io.ReadAll(resp.Body) + + // Check for API errors in response body + var result map[string]interface{} + if err := json.Unmarshal(body, &result); err == nil { + if errMsg, ok := result["error"]; ok && errMsg != nil { + // Return 400 to indicate API error (even if HTTP was 200) + return latencyMs, 400, nil + } + } + + return latencyMs, resp.StatusCode, nil +} + +// ============================================================================ +// Jupiter Public API (Solana only, FREE - 10 req/sec) +// ============================================================================ + +func callJupiterPublicQuoteAPI() (float64, int, error) { + params := url.Values{} + params.Add("inputMint", solanaConfig.TokenIn) + params.Add("outputMint", solanaConfig.TokenOut) + params.Add("amount", solanaConfig.Amount) + params.Add("slippageBps", "50") + + fullURL := fmt.Sprintf("%s?%s", jupiterPublicURL, params.Encode()) + + req, err := http.NewRequest("GET", fullURL, nil) + if err != nil { + return 0, 0, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Accept", "application/json") + + startTime := time.Now() + resp, err := quoteHTTPClient.Do(req) + latencyMs := float64(time.Since(startTime).Milliseconds()) + + if err != nil { + return latencyMs, 0, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + _, _ = io.ReadAll(resp.Body) + + return latencyMs, resp.StatusCode, nil +} + +// ============================================================================ +// OpenOcean API (Multi-chain, FREE) +// ============================================================================ + +func callOpenOceanQuoteAPI(chain QuoteChainConfig) (float64, int, error) { + endpoint := fmt.Sprintf("%s/%s/quote", openOceanQuoteURL, chain.OpenOceanChain) + + params := url.Values{} + params.Add("inTokenAddress", chain.TokenIn) + params.Add("outTokenAddress", chain.TokenOut) + params.Add("amount", chain.Amount) + params.Add("gasPrice", "5") + + fullURL := fmt.Sprintf("%s?%s", endpoint, params.Encode()) + + req, err := http.NewRequest("GET", fullURL, nil) + if err != nil { + return 0, 0, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Accept", "application/json") + + startTime := time.Now() + resp, err := quoteHTTPClient.Do(req) + latencyMs := float64(time.Since(startTime).Milliseconds()) + + if err != nil { + return latencyMs, 0, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + _, _ = io.ReadAll(resp.Body) + + return latencyMs, resp.StatusCode, nil +} + +// ============================================================================ +// ParaSwap API (Multi-chain, FREE) +// ============================================================================ + +func callParaSwapQuoteAPI(chain QuoteChainConfig) (float64, int, error) { + params := url.Values{} + params.Add("srcToken", chain.TokenIn) + params.Add("destToken", chain.TokenOut) + params.Add("amount", chain.Amount) + params.Add("srcDecimals", fmt.Sprintf("%d", chain.Decimals)) + params.Add("destDecimals", "18") // Native tokens are 18 decimals + params.Add("network", chain.ChainID) + + fullURL := fmt.Sprintf("%s?%s", paraSwapQuoteURL, params.Encode()) + + req, err := http.NewRequest("GET", fullURL, nil) + if err != nil { + return 0, 0, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Accept", "application/json") + + startTime := time.Now() + resp, err := quoteHTTPClient.Do(req) + latencyMs := float64(time.Since(startTime).Milliseconds()) + + if err != nil { + return latencyMs, 0, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + _, _ = io.ReadAll(resp.Body) + + return latencyMs, resp.StatusCode, nil +} + +// ============================================================================ +// Li.Fi API (Multi-chain, FREE) +// ============================================================================ + +func callLifiQuoteAPI(chain QuoteChainConfig) (float64, int, error) { + params := url.Values{} + params.Add("fromChain", chain.ChainID) + params.Add("toChain", chain.ChainID) // Same chain swap + params.Add("fromToken", chain.TokenIn) + params.Add("toToken", chain.TokenOut) + params.Add("fromAmount", chain.Amount) + params.Add("fromAddress", dummyWalletAddressEVM) // Required by Li.Fi + + fullURL := fmt.Sprintf("%s?%s", lifiQuoteURL, params.Encode()) + + req, err := http.NewRequest("GET", fullURL, nil) + if err != nil { + return 0, 0, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Accept", "application/json") + + startTime := time.Now() + resp, err := quoteHTTPClient.Do(req) + latencyMs := float64(time.Since(startTime).Milliseconds()) + + if err != nil { + return latencyMs, 0, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + _, _ = io.ReadAll(resp.Body) + + return latencyMs, resp.StatusCode, nil +} + +// ============================================================================ +// KyberSwap API (Multi-chain, FREE) +// ============================================================================ + +func callKyberSwapQuoteAPI(chain QuoteChainConfig) (float64, int, error) { + endpoint := fmt.Sprintf("%s/%s/api/v1/routes", kyberSwapQuoteURL, chain.KyberChainKey) + + params := url.Values{} + params.Add("tokenIn", chain.TokenIn) + params.Add("tokenOut", chain.TokenOut) + params.Add("amountIn", chain.Amount) + + fullURL := fmt.Sprintf("%s?%s", endpoint, params.Encode()) + + req, err := http.NewRequest("GET", fullURL, nil) + if err != nil { + return 0, 0, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Accept", "application/json") + + startTime := time.Now() + resp, err := quoteHTTPClient.Do(req) + latencyMs := float64(time.Since(startTime).Milliseconds()) + + if err != nil { + return latencyMs, 0, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + _, _ = io.ReadAll(resp.Body) + + return latencyMs, resp.StatusCode, nil +} + + +// ============================================================================ +// Main monitoring function +// ============================================================================ + +func performQuoteAPIChecks(config *Config) { + timestamp := time.Now().UTC().Format("2006-01-02 15:04:05") + + fmt.Printf("\n[QUOTE-API][%s] === Starting quote API latency checks ===\n", timestamp) + + // ========== SOLANA QUOTES ========== + + // Mobula (Solana) + latencyMs, statusCode, err := callMobulaSwapQuoteAPI( + "solana", + "solana", + solanaConfig.TokenIn, + solanaConfig.TokenOut, + "100", // 100 USDC + config.MobulaAPIKey, + ) + if err != nil || statusCode >= 400 { + RecordQuoteAPIError("mobula", "solana", getErrorType(statusCode), config.MonitorRegion) + fmt.Printf("[QUOTE-API][%s][mobula][solana] %s | Latency: %.0fms | Status: %d\n", + timestamp, getStatusEmoji(statusCode), latencyMs, statusCode) + } else { + RecordQuoteAPILatency("mobula", "solana", latencyMs, statusCode, config.MonitorRegion) + fmt.Printf("[QUOTE-API][%s][mobula][solana] %s | Latency: %.0fms | Status: %d\n", + timestamp, getStatusEmoji(statusCode), latencyMs, statusCode) + } + + // Jupiter (Solana only - FREE public API) + latencyMs, statusCode, err = callJupiterPublicQuoteAPI() + if err != nil || statusCode >= 400 { + RecordQuoteAPIError("jupiter", "solana", getErrorType(statusCode), config.MonitorRegion) + fmt.Printf("[QUOTE-API][%s][jupiter][solana] %s | Latency: %.0fms | Status: %d\n", + timestamp, getStatusEmoji(statusCode), latencyMs, statusCode) + } else { + RecordQuoteAPILatency("jupiter", "solana", latencyMs, statusCode, config.MonitorRegion) + fmt.Printf("[QUOTE-API][%s][jupiter][solana] %s | Latency: %.0fms | Status: %d\n", + timestamp, getStatusEmoji(statusCode), latencyMs, statusCode) + } + + // ========== EVM QUOTES ========== + + // Test EVM chains with FREE APIs: Mobula (Base), OpenOcean, ParaSwap, Li.Fi, KyberSwap + for _, chain := range evmQuoteChains { + // Mobula (Base - chain where MobulaRouter is deployed) + if chain.Name == "base" { + latencyMs, statusCode, err := callMobulaSwapQuoteAPI( + "evm:"+chain.ChainID, + chain.Name, + chain.TokenIn, + chain.TokenOut, + "100", // 100 USDC + config.MobulaAPIKey, + ) + if err != nil || statusCode >= 400 { + RecordQuoteAPIError("mobula", chain.Name, getErrorType(statusCode), config.MonitorRegion) + fmt.Printf("[QUOTE-API][%s][mobula][%s] %s | Latency: %.0fms | Status: %d\n", + timestamp, chain.Name, getStatusEmoji(statusCode), latencyMs, statusCode) + } else { + RecordQuoteAPILatency("mobula", chain.Name, latencyMs, statusCode, config.MonitorRegion) + fmt.Printf("[QUOTE-API][%s][mobula][%s] %s | Latency: %.0fms | Status: %d\n", + timestamp, chain.Name, getStatusEmoji(statusCode), latencyMs, statusCode) + } + } + + // OpenOcean (FREE) + latencyMs, statusCode, err := callOpenOceanQuoteAPI(chain) + if err != nil || statusCode >= 400 { + RecordQuoteAPIError("openocean", chain.Name, getErrorType(statusCode), config.MonitorRegion) + fmt.Printf("[QUOTE-API][%s][openocean][%s] %s | Latency: %.0fms | Status: %d\n", + timestamp, chain.Name, getStatusEmoji(statusCode), latencyMs, statusCode) + } else { + RecordQuoteAPILatency("openocean", chain.Name, latencyMs, statusCode, config.MonitorRegion) + fmt.Printf("[QUOTE-API][%s][openocean][%s] %s | Latency: %.0fms | Status: %d\n", + timestamp, chain.Name, getStatusEmoji(statusCode), latencyMs, statusCode) + } + + // ParaSwap (FREE) + latencyMs, statusCode, err = callParaSwapQuoteAPI(chain) + if err != nil || statusCode >= 400 { + RecordQuoteAPIError("paraswap", chain.Name, getErrorType(statusCode), config.MonitorRegion) + fmt.Printf("[QUOTE-API][%s][paraswap][%s] %s | Latency: %.0fms | Status: %d\n", + timestamp, chain.Name, getStatusEmoji(statusCode), latencyMs, statusCode) + } else { + RecordQuoteAPILatency("paraswap", chain.Name, latencyMs, statusCode, config.MonitorRegion) + fmt.Printf("[QUOTE-API][%s][paraswap][%s] %s | Latency: %.0fms | Status: %d\n", + timestamp, chain.Name, getStatusEmoji(statusCode), latencyMs, statusCode) + } + + // Li.Fi (FREE) + latencyMs, statusCode, err = callLifiQuoteAPI(chain) + if err != nil || statusCode >= 400 { + RecordQuoteAPIError("lifi", chain.Name, getErrorType(statusCode), config.MonitorRegion) + fmt.Printf("[QUOTE-API][%s][lifi][%s] %s | Latency: %.0fms | Status: %d\n", + timestamp, chain.Name, getStatusEmoji(statusCode), latencyMs, statusCode) + } else { + RecordQuoteAPILatency("lifi", chain.Name, latencyMs, statusCode, config.MonitorRegion) + fmt.Printf("[QUOTE-API][%s][lifi][%s] %s | Latency: %.0fms | Status: %d\n", + timestamp, chain.Name, getStatusEmoji(statusCode), latencyMs, statusCode) + } + + // KyberSwap (FREE) + latencyMs, statusCode, err = callKyberSwapQuoteAPI(chain) + if err != nil || statusCode >= 400 { + RecordQuoteAPIError("kyberswap", chain.Name, getErrorType(statusCode), config.MonitorRegion) + fmt.Printf("[QUOTE-API][%s][kyberswap][%s] %s | Latency: %.0fms | Status: %d\n", + timestamp, chain.Name, getStatusEmoji(statusCode), latencyMs, statusCode) + } else { + RecordQuoteAPILatency("kyberswap", chain.Name, latencyMs, statusCode, config.MonitorRegion) + fmt.Printf("[QUOTE-API][%s][kyberswap][%s] %s | Latency: %.0fms | Status: %d\n", + timestamp, chain.Name, getStatusEmoji(statusCode), latencyMs, statusCode) + } + } + + // Jupiter (Solana) - Requires API key, skip if not available + // TODO: Add JUPITER_API_KEY to config if needed + // latencyMs, statusCode, err := callJupiterQuoteAPI("") + // ... + + fmt.Printf("[QUOTE-API][%s] === Quote API checks completed ===\n\n", timestamp) +} + +func getErrorType(statusCode int) string { + if statusCode >= 500 { + return "server_error" + } else if statusCode >= 400 { + return "client_error" + } else if statusCode == 0 { + return "timeout_error" + } + return "request_error" +} + +func getStatusEmoji(statusCode int) string { + if statusCode >= 400 { + return "✗" + } else if statusCode >= 300 { + return "⚠" + } + return "✓" +} + +// runQuoteAPIMonitor starts the quote API latency monitoring +func runQuoteAPIMonitor(config *Config, stopChan <-chan struct{}) { + fmt.Println("Starting Quote API Latency Monitor...") + fmt.Println(" Comparing: Mobula, Jupiter, OpenOcean, ParaSwap, Li.Fi, KyberSwap") + fmt.Println(" Mobula: Solana + Base") + fmt.Println(" Jupiter: Solana") + fmt.Println(" Others: Ethereum, Base, BNB") + fmt.Println(" Test: 100 USDC → Native token quote") + fmt.Println(" Interval: 30 seconds") + fmt.Println() + + // Create ticker for 30 second intervals + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + // Run once immediately + performQuoteAPIChecks(config) + + // Then run every 30 seconds + for { + select { + case <-stopChan: + fmt.Println("Quote API monitor stopped") + return + case <-ticker.C: + performQuoteAPIChecks(config) + } + } +} + +// Helper to pretty print JSON for debugging +func prettyPrintJSON(data []byte) { + var prettyJSON map[string]interface{} + if err := json.Unmarshal(data, &prettyJSON); err == nil { + formatted, _ := json.MarshalIndent(prettyJSON, "", " ") + fmt.Printf("%s\n", formatted) + } +} diff --git a/harnesses/aggregator-latency-benchmark/cmd/script/scrape_session.go b/harnesses/aggregator-latency-benchmark/cmd/script/scrape_session.go new file mode 100644 index 00000000..4607912c --- /dev/null +++ b/harnesses/aggregator-latency-benchmark/cmd/script/scrape_session.go @@ -0,0 +1,61 @@ +package main + +import ( + "context" + "fmt" + "time" + + "github.com/chromedp/cdproto/network" + "github.com/chromedp/chromedp" +) + +func ScrapeDefinedSessionCookie() (string, error) { + // Create Chrome context with headless mode + opts := append(chromedp.DefaultExecAllocatorOptions[:], + chromedp.Flag("headless", true), + chromedp.Flag("disable-gpu", true), + chromedp.Flag("no-sandbox", true), + chromedp.Flag("disable-dev-shm-usage", true), + chromedp.UserAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"), + ) + + allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...) + defer cancel() + + ctx, cancel := chromedp.NewContext(allocCtx) + defer cancel() + + ctx, cancel = context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + var sessionCookie string + + err := chromedp.Run(ctx, + chromedp.Navigate("https://www.defined.fi/"), + chromedp.WaitVisible(`body`, chromedp.ByQuery), + chromedp.Sleep(5*time.Second), + + chromedp.ActionFunc(func(ctx context.Context) error { + cookieParams, err := network.GetCookies().Do(ctx) + if err != nil { + return fmt.Errorf("failed to get cookies: %w", err) + } + + for _, cookie := range cookieParams { + if cookie.Name == "session" { + sessionCookie = cookie.Value + return nil + } + } + + return fmt.Errorf("session cookie not found") + }), + ) + + if err != nil { + return "", fmt.Errorf("failed to scrape session cookie: %w", err) + } + + return sessionCookie, nil +} + diff --git a/harnesses/aggregator-latency-benchmark/cmd/script/update_metrics_calls.sh b/harnesses/aggregator-latency-benchmark/cmd/script/update_metrics_calls.sh new file mode 100755 index 00000000..bd84baa2 --- /dev/null +++ b/harnesses/aggregator-latency-benchmark/cmd/script/update_metrics_calls.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# Auto-update all RecordX calls to include region parameter + +FILES="head_lag_monitor.go codex_rest_monitor.go mobula_rest_monitor.go quote_api_monitor.go geckoterminal_monitor.go moralis_rest_monitor.go metadata_coverage_monitor.go" + +for file in $FILES; do + if [ -f "$file" ]; then + echo "Updating $file..." + # Use perl for multiline regex + perl -i -pe 's/RecordHeadLag\(([^)]+)\)/RecordHeadLag($1, config.MonitorRegion)/g' "$file" + perl -i -pe 's/RecordRESTLatency\(([^)]+)\)/RecordRESTLatency($1, config.MonitorRegion)/g' "$file" + perl -i -pe 's/RecordRESTError\(([^)]+)\)/RecordRESTError($1, config.MonitorRegion)/g' "$file" + perl -i -pe 's/RecordQuoteAPILatency\(([^)]+)\)/RecordQuoteAPILatency($1, config.MonitorRegion)/g' "$file" + perl -i -pe 's/RecordQuoteAPIError\(([^)]+)\)/RecordQuoteAPIError($1, config.MonitorRegion)/g' "$file" + perl -i -pe 's/RecordHeadLagError\(([^)]+)\)/RecordHeadLagError($1, config.MonitorRegion)/g' "$file" + perl -i -pe 's/RecordCodexBlockNumber\(([^)]+)\)/RecordCodexBlockNumber($1, config.MonitorRegion)/g' "$file" + perl -i -pe 's/RecordMetadataCoverage\(([^)]+)\)/RecordMetadataCoverage($1, config.MonitorRegion)/g' "$file" + perl -i -pe 's/RecordMetadataLatency\(([^)]+)\)/RecordMetadataLatency($1, config.MonitorRegion)/g' "$file" + perl -i -pe 's/RecordPoolDiscoveryLatency\(([^)]+)\)/RecordPoolDiscoveryLatency($1, config.MonitorRegion)/g' "$file" + perl -i -pe 's/RecordPoolDiscoveryError\(([^)]+)\)/RecordPoolDiscoveryError($1, config.MonitorRegion)/g' "$file" + fi +done + +echo "✓ All files updated" diff --git a/harnesses/aggregator-latency-benchmark/docker-compose.yml b/harnesses/aggregator-latency-benchmark/docker-compose.yml new file mode 100644 index 00000000..7536daee --- /dev/null +++ b/harnesses/aggregator-latency-benchmark/docker-compose.yml @@ -0,0 +1,82 @@ +services: + monitor: + build: + context: . + dockerfile: Dockerfile + container_name: latency_monitor + ports: + - "2112:2112" + environment: + - COINGECKO_API_KEY=${COINGECKO_API_KEY} + - MOBULA_API_KEY=${MOBULA_API_KEY} + - DEFINED_SESSION_COOKIE=${DEFINED_SESSION_COOKIE} + networks: + - monitoring + restart: unless-stopped + + prometheus: + image: prom/prometheus:latest + container_name: prometheus + ports: + - "9090:9090" + volumes: + - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml + - ./monitoring/alert_rules.yml:/etc/prometheus/alert_rules.yml + - prometheus_data:/prometheus + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + - '--web.console.libraries=/etc/prometheus/console_libraries' + - '--web.console.templates=/etc/prometheus/consoles' + - '--web.enable-lifecycle' + networks: + - monitoring + restart: unless-stopped + depends_on: + - monitor + + alertmanager: + image: prom/alertmanager:latest + container_name: alertmanager + ports: + - "9093:9093" + volumes: + - ./monitoring/alertmanager.yml:/etc/alertmanager/alertmanager.yml + command: + - '--config.file=/etc/alertmanager/alertmanager.yml' + - '--storage.path=/alertmanager' + networks: + - monitoring + restart: unless-stopped + + grafana: + image: grafana/grafana:latest + container_name: grafana + ports: + - "3000:3000" + volumes: + - ./monitoring/grafana/provisioning:/etc/grafana/provisioning + - ./monitoring/grafana/dashboards:/dashboards-source:ro + - ./grafana-entrypoint.sh:/grafana-entrypoint.sh:ro + entrypoint: ["/bin/sh", "/grafana-entrypoint.sh"] + environment: + - GF_SECURITY_ADMIN_USER=admin + - GF_SECURITY_ADMIN_PASSWORD=${GF_SECURITY_ADMIN_PASSWORD:-admin} + - GF_USERS_ALLOW_SIGN_UP=false + - GF_SERVER_ROOT_URL=${GF_SERVER_ROOT_URL:-http://localhost:3000} + - GF_INSTALL_PLUGINS=grafana-clock-panel + - GF_AUTH_ANONYMOUS_ENABLED=true + - GF_AUTH_ANONYMOUS_ORG_ROLE=Viewer + - GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH=/var/lib/grafana/dashboards/head_lag.json + networks: + - monitoring + depends_on: + - prometheus + restart: unless-stopped + +networks: + monitoring: + driver: bridge + +volumes: + prometheus_data: diff --git a/harnesses/aggregator-latency-benchmark/go.mod b/harnesses/aggregator-latency-benchmark/go.mod new file mode 100644 index 00000000..ed17b543 --- /dev/null +++ b/harnesses/aggregator-latency-benchmark/go.mod @@ -0,0 +1,28 @@ +module mobula_latency_competitor + +go 1.24.4 + +require ( + github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 + github.com/chromedp/chromedp v0.14.2 + github.com/gorilla/websocket v1.5.3 + 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/chromedp/sysutil v1.1.0 // indirect + github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect + github.com/gobwas/httphead v0.1.0 // indirect + github.com/gobwas/pool v0.2.1 // indirect + github.com/gobwas/ws v1.4.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/aggregator-latency-benchmark/go.sum b/harnesses/aggregator-latency-benchmark/go.sum new file mode 100644 index 00000000..f594bc89 --- /dev/null +++ b/harnesses/aggregator-latency-benchmark/go.sum @@ -0,0 +1,67 @@ +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/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E= +github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k= +github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM= +github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo= +github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM= +github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= +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/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs= +github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= +github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= +github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= +github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= +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/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +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/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= +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/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= +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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +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/aggregator-latency-benchmark/grafana-entrypoint.sh b/harnesses/aggregator-latency-benchmark/grafana-entrypoint.sh new file mode 100755 index 00000000..ccf63ca7 --- /dev/null +++ b/harnesses/aggregator-latency-benchmark/grafana-entrypoint.sh @@ -0,0 +1,29 @@ +#!/bin/sh + +echo "=== GRAFANA ENTRYPOINT SCRIPT STARTING ===" +echo "Working directory: $(pwd)" +echo "User: $(whoami)" + +# Create dashboards directory +mkdir -p /var/lib/grafana/dashboards + +# Debug: print environment variables +echo "Checking environment..." +env | grep -E '(RAILWAY|HIDE_QUOTE)' || echo "No RAILWAY/HIDE_QUOTE variables found" + +# Copy dashboards from source +if [ "$HIDE_QUOTE_DASHBOARD" = "true" ]; then + echo "HIDE_QUOTE_DASHBOARD=true - hiding Quote API Latency Benchmark dashboard" + cp /dashboards-source/head_lag.json /var/lib/grafana/dashboards/ +else + echo "Copying all dashboards" + cp /dashboards-source/*.json /var/lib/grafana/dashboards/ +fi + +echo "Dashboards copied:" +ls -la /var/lib/grafana/dashboards/ + +echo "=== GRAFANA ENTRYPOINT SCRIPT COMPLETE ===" + +# Start Grafana with default entrypoint +exec /run.sh diff --git a/harnesses/aggregator-latency-benchmark/grafana/Dockerfile b/harnesses/aggregator-latency-benchmark/grafana/Dockerfile new file mode 100644 index 00000000..eb500db9 --- /dev/null +++ b/harnesses/aggregator-latency-benchmark/grafana/Dockerfile @@ -0,0 +1,34 @@ +FROM grafana/grafana:latest + +USER root + +# Cache buster - update this to force rebuild: v7 +ARG CACHE_BUST=7 + +# Copy provisioning configs and dashboards source (we're in grafana folder) +COPY provisioning /etc/grafana/provisioning +COPY dashboards /dashboards-source + +# Copy entrypoint script +COPY grafana-entrypoint.sh /grafana-entrypoint.sh +RUN chmod +x /grafana-entrypoint.sh + +# Set permissions +RUN chown -R grafana:root /etc/grafana/provisioning /dashboards-source + +USER grafana + +# Environment variables +ENV GF_AUTH_ANONYMOUS_ENABLED=true +ENV GF_AUTH_ANONYMOUS_ORG_ROLE=Viewer +ENV GF_SECURITY_ADMIN_USER=admin +ENV GF_SECURITY_ADMIN_PASSWORD=admin +ENV GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH=/var/lib/grafana/dashboards/head_lag.json + +# Prometheus datasource URL (overridden by staging deployment) +ENV PROMETHEUS_URL=http://prometheus.railway.internal:9090 + +EXPOSE 3000 + +# Use custom entrypoint +ENTRYPOINT ["/bin/sh", "/grafana-entrypoint.sh"] diff --git a/harnesses/aggregator-latency-benchmark/grafana/dashboards/disabled/mobula_pulse_vs_fasttrade.json b/harnesses/aggregator-latency-benchmark/grafana/dashboards/disabled/mobula_pulse_vs_fasttrade.json new file mode 100644 index 00000000..373e2f06 --- /dev/null +++ b/harnesses/aggregator-latency-benchmark/grafana/dashboards/disabled/mobula_pulse_vs_fasttrade.json @@ -0,0 +1,595 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Latency (ms)", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 2000 + }, + { + "color": "orange", + "value": 5000 + }, + { + "color": "red", + "value": 10000 + } + ] + }, + "unit": "ms" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*Pulse.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "blue", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*Fast-Trade.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "orange", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": ["mean", "last", "max", "min"], + "displayMode": "table", + "placement": "right", + "showLegend": true, + "sortBy": "Mean", + "sortDesc": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "pool_discovery_latency_milliseconds{aggregator=\"mobula\"}", + "legendFormat": "[{{region}}] Pulse V2 - {{chain}}", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "fast_trade_latency_milliseconds{aggregator=\"mobula\"}", + "legendFormat": "[{{region}}] Fast-Trade - {{chain}}", + "range": true, + "refId": "B" + } + ], + "title": "Mobula: Pulse V2 (Discovery) vs Fast-Trade (Swap Indexation)", + "description": "Pulse V2 measures pool discovery latency (on-chain creation → Mobula indexation). Fast-Trade measures swap indexation latency (on-chain swap → WebSocket receipt).", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 2000 + }, + { + "color": "orange", + "value": 5000 + }, + { + "color": "red", + "value": 10000 + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 10 + }, + "id": 3, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": ["last"], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "fast_trade_latency_milliseconds{aggregator=\"mobula\"}", + "legendFormat": "[{{region}}] {{chain}}", + "range": true, + "refId": "A" + } + ], + "title": "Fast-Trade - Current Swap Indexation Latency", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Latency (ms)", + "axisPlacement": "auto", + "drawStyle": "line", + "fillOpacity": 10, + "lineInterpolation": "linear", + "lineWidth": 2, + "showPoints": "auto" + }, + "unit": "ms", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 2000 + }, + { + "color": "red", + "value": 5000 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 10 + }, + "id": 4, + "options": { + "legend": { + "calcs": ["last"], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "pool_discovery_latency_milliseconds{aggregator=\"mobula\",chain=\"solana\"}", + "legendFormat": "[{{region}}] Pulse V2", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "fast_trade_latency_milliseconds{aggregator=\"mobula\",chain=\"solana\"}", + "legendFormat": "[{{region}}] Fast-Trade", + "refId": "B" + } + ], + "title": "Solana - Pulse V2 vs Fast-Trade", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Latency (ms)", + "axisPlacement": "auto", + "drawStyle": "line", + "fillOpacity": 10, + "lineInterpolation": "linear", + "lineWidth": 2, + "showPoints": "auto" + }, + "unit": "ms", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 2000 + }, + { + "color": "red", + "value": 5000 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 10 + }, + "id": 5, + "options": { + "legend": { + "calcs": ["last"], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "pool_discovery_latency_milliseconds{aggregator=\"mobula\",chain=\"base\"}", + "legendFormat": "[{{region}}] Pulse V2", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "fast_trade_latency_milliseconds{aggregator=\"mobula\",chain=\"base\"}", + "legendFormat": "[{{region}}] Fast-Trade", + "refId": "B" + } + ], + "title": "Base - Pulse V2 vs Fast-Trade", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Latency (ms)", + "axisPlacement": "auto", + "drawStyle": "line", + "fillOpacity": 10, + "lineInterpolation": "linear", + "lineWidth": 2, + "showPoints": "auto" + }, + "unit": "ms", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 2000 + }, + { + "color": "red", + "value": 5000 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 10 + }, + "id": 6, + "options": { + "legend": { + "calcs": ["last"], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "pool_discovery_latency_milliseconds{aggregator=\"mobula\",chain=\"ethereum\"}", + "legendFormat": "[{{region}}] Pulse V2", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "fast_trade_latency_milliseconds{aggregator=\"mobula\",chain=\"ethereum\"}", + "legendFormat": "[{{region}}] Fast-Trade", + "refId": "B" + } + ], + "title": "Ethereum - Pulse V2 vs Fast-Trade", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Latency (ms)", + "axisPlacement": "auto", + "drawStyle": "line", + "fillOpacity": 10, + "lineInterpolation": "linear", + "lineWidth": 2, + "showPoints": "auto" + }, + "unit": "ms", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 2000 + }, + { + "color": "red", + "value": 5000 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 18 + }, + "id": 7, + "options": { + "legend": { + "calcs": ["last"], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "pool_discovery_latency_milliseconds{aggregator=\"mobula\",chain=\"bnb\"}", + "legendFormat": "[{{region}}] Pulse V2", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "fast_trade_latency_milliseconds{aggregator=\"mobula\",chain=\"bnb\"}", + "legendFormat": "[{{region}}] Fast-Trade", + "refId": "B" + } + ], + "title": "BNB - Pulse V2 vs Fast-Trade", + "type": "timeseries" + } + ], + "refresh": "30s", + "schemaVersion": 39, + "tags": ["mobula", "pulse", "fast-trade", "comparison"], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Mobula: Pulse V2 vs Fast-Trade Comparison", + "uid": "mobula_pulse_vs_fasttrade", + "version": 0, + "weekStart": "" +} diff --git a/harnesses/aggregator-latency-benchmark/grafana/dashboards/disabled/pulse_vs_codex.json b/harnesses/aggregator-latency-benchmark/grafana/dashboards/disabled/pulse_vs_codex.json new file mode 100644 index 00000000..8ced6186 --- /dev/null +++ b/harnesses/aggregator-latency-benchmark/grafana/dashboards/disabled/pulse_vs_codex.json @@ -0,0 +1,661 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Direct comparison of Pulse vs Codex head lag on monitored pools", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Latency (seconds)", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 5 + } + ] + }, + "unit": "s" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*pulse.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "blue", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*codex.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": ["lastNotNull", "mean", "max"], + "displayMode": "table", + "placement": "right", + "showLegend": true, + "sortBy": "Mean", + "sortDesc": false + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "head_lag_seconds{aggregator=~\"pulse|codex\",chain=~\"$chain\"}", + "legendFormat": "{{chain}} | {{aggregator}}", + "range": true, + "refId": "A" + } + ], + "title": "Pulse vs Codex - Head Lag Comparison", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Shows which provider is faster (negative = Pulse faster, positive = Codex faster)", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": true, + "axisColorMode": "text", + "axisLabel": "Delta (seconds)", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 30, + "gradientMode": "hue", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "area" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": -1 + }, + { + "color": "red", + "value": 1 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 24, + "x": 0, + "y": 10 + }, + "id": 2, + "options": { + "legend": { + "calcs": ["mean", "lastNotNull"], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "(head_lag_seconds{aggregator=\"pulse\",chain=~\"$chain\"} - ignoring(aggregator) head_lag_seconds{aggregator=\"codex\",chain=~\"$chain\"})", + "legendFormat": "{{chain}} - Pulse vs Codex delta", + "range": true, + "refId": "A" + } + ], + "title": "Latency Delta: Pulse - Codex (negative = Pulse faster)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "color-text" + }, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 1 + }, + { + "color": "orange", + "value": 2 + }, + { + "color": "red", + "value": 5 + } + ] + }, + "unit": "s" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Chain" + }, + "properties": [ + { + "id": "custom.width", + "value": 120 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Provider" + }, + "properties": [ + { + "id": "custom.width", + "value": 100 + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 19 + }, + "id": 3, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": ["sum"], + "show": false + }, + "showHeader": true, + "sortBy": [ + { + "desc": false, + "displayName": "Mean (5m)" + } + ] + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "exemplar": false, + "expr": "head_lag_seconds{aggregator=~\"pulse|codex\",chain=~\"$chain\"}", + "format": "table", + "instant": true, + "legendFormat": "__auto", + "range": false, + "refId": "Current" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "exemplar": false, + "expr": "avg_over_time(head_lag_seconds{aggregator=~\"pulse|codex\",chain=~\"$chain\"}[5m])", + "format": "table", + "hide": false, + "instant": true, + "legendFormat": "__auto", + "range": false, + "refId": "Mean5m" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "exemplar": false, + "expr": "max_over_time(head_lag_seconds{aggregator=~\"pulse|codex\",chain=~\"$chain\"}[5m])", + "format": "table", + "hide": false, + "instant": true, + "legendFormat": "__auto", + "range": false, + "refId": "Max5m" + } + ], + "title": "Current Stats - Pulse vs Codex", + "transformations": [ + { + "id": "merge", + "options": {} + }, + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true, + "__name__": true, + "deployment": true, + "instance": true, + "job": true, + "region": true + }, + "includeByName": {}, + "indexByName": {}, + "renameByName": { + "aggregator": "Provider", + "chain": "Chain" + } + } + }, + { + "id": "groupBy", + "options": { + "fields": { + "Chain": { + "aggregations": [], + "operation": "groupby" + }, + "Provider": { + "aggregations": [], + "operation": "groupby" + }, + "Value #Current": { + "aggregations": ["lastNotNull"], + "operation": "aggregate" + }, + "Value #Max5m": { + "aggregations": ["lastNotNull"], + "operation": "aggregate" + }, + "Value #Mean5m": { + "aggregations": ["lastNotNull"], + "operation": "aggregate" + } + } + } + }, + { + "id": "organize", + "options": { + "excludeByName": {}, + "includeByName": {}, + "indexByName": { + "Chain": 0, + "Provider": 1, + "Value #Current (lastNotNull)": 2, + "Value #Max5m (lastNotNull)": 4, + "Value #Mean5m (lastNotNull)": 3 + }, + "renameByName": { + "Value #Current (lastNotNull)": "Current", + "Value #Max5m (lastNotNull)": "Max (5m)", + "Value #Mean5m (lastNotNull)": "Mean (5m)" + } + } + } + ], + "type": "table" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 1 + }, + { + "color": "red", + "value": 3 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 27 + }, + "id": 4, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "head_lag_seconds{aggregator=\"pulse\",chain=~\"$chain\"}", + "instant": true, + "legendFormat": "{{chain}}", + "refId": "A" + } + ], + "title": "Pulse - Current Head Lag", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 1 + }, + { + "color": "red", + "value": 3 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 27 + }, + "id": 5, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "head_lag_seconds{aggregator=\"codex\",chain=~\"$chain\"}", + "instant": true, + "legendFormat": "{{chain}}", + "refId": "A" + } + ], + "title": "Codex - Current Head Lag", + "type": "gauge" + } + ], + "refresh": "30s", + "schemaVersion": 39, + "tags": ["pulse", "codex", "comparison", "head-lag"], + "templating": { + "list": [ + { + "current": { + "selected": true, + "text": ["All"], + "value": ["$__all"] + }, + "hide": 0, + "includeAll": true, + "label": "Chain", + "multi": true, + "name": "chain", + "options": [ + { + "selected": true, + "text": "All", + "value": "$__all" + }, + { + "selected": false, + "text": "solana", + "value": "solana" + }, + { + "selected": false, + "text": "base", + "value": "base" + }, + { + "selected": false, + "text": "bnb", + "value": "bnb" + } + ], + "query": "solana,base,bnb", + "queryValue": "", + "skipUrlSync": false, + "type": "custom" + } + ] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Pulse vs Codex - Head Lag Comparison", + "uid": "pulse_vs_codex", + "version": 1, + "weekStart": "" +} diff --git a/harnesses/aggregator-latency-benchmark/grafana/dashboards/disabled/quote_api_latency.json b/harnesses/aggregator-latency-benchmark/grafana/dashboards/disabled/quote_api_latency.json new file mode 100644 index 00000000..d0e12537 --- /dev/null +++ b/harnesses/aggregator-latency-benchmark/grafana/dashboards/disabled/quote_api_latency.json @@ -0,0 +1,916 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 3, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 500 + }, + { + "color": "red", + "value": 2000 + } + ] + }, + "unit": "ms" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "orange", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*jupiter.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*kyberswap.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "blue", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*lifi.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "purple", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*paraswap.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "yellow", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*openocean.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": ["mean", "lastNotNull", "max", "min"], + "displayMode": "table", + "placement": "right", + "showLegend": true, + "sortBy": "Mean", + "sortDesc": false + }, + "tooltip": { + "mode": "multi", + "sort": "asc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.50, sum(rate(quote_api_latency_milliseconds_bucket{chain!=\"arbitrum\"}[1m])) by (le, provider, chain))", + "legendFormat": "{{provider}} - {{chain}} (P50)", + "range": true, + "refId": "A" + } + ], + "title": "Quote API Latency Comparison - All Providers (P50)", + "description": "Median Quote API response time - Mobula vs competitors (30s polling interval)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 300 + }, + { + "color": "orange", + "value": 700 + }, + { + "color": "red", + "value": 1500 + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 24, + "x": 0, + "y": 10 + }, + "id": 2, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.50, sum(rate(quote_api_latency_milliseconds_bucket[5m])) by (le, provider))", + "legendFormat": "{{provider}}", + "range": true, + "refId": "A" + } + ], + "title": "Latest Quote API Latency (P50) by Provider", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 3, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 1000 + } + ] + }, + "unit": "ms" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "orange", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*jupiter.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 3, + "options": { + "legend": { + "calcs": ["mean", "lastNotNull", "max"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "asc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.50, sum(rate(quote_api_latency_milliseconds_bucket{chain=\"solana\"}[1m])) by (le, provider))", + "legendFormat": "{{provider}} (P50)", + "range": true, + "refId": "A" + } + ], + "title": "🌞 Solana Quote APIs - Mobula vs Jupiter", + "description": "Solana swap quote latency comparison", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 3, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 1000 + } + ] + }, + "unit": "ms" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "orange", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 16 + }, + "id": 4, + "options": { + "legend": { + "calcs": ["mean", "lastNotNull", "max"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "asc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.50, sum(rate(quote_api_latency_milliseconds_bucket{chain=\"base\"}[1m])) by (le, provider))", + "legendFormat": "{{provider}} (P50)", + "range": true, + "refId": "A" + } + ], + "title": "🔵 Base Quote APIs - Mobula vs Competitors", + "description": "Base chain swap quote latency comparison", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 3, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 1000 + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 24 + }, + "id": 6, + "options": { + "legend": { + "calcs": ["mean", "lastNotNull", "max"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "asc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.50, sum(rate(quote_api_latency_milliseconds_bucket{chain=\"ethereum\"}[1m])) by (le, provider))", + "legendFormat": "{{provider}} (P50)", + "range": true, + "refId": "A" + } + ], + "title": "⟠ Ethereum Quote APIs - Competitors Only", + "description": "Ethereum chain swap quote latency (Mobula not deployed)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "bars", + "fillOpacity": 80, + "gradientMode": "hue", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 12, + "x": 0, + "y": 32 + }, + "id": 7, + "options": { + "legend": { + "calcs": ["sum"], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(increase(quote_api_errors_total{chain!=\"arbitrum\"}[5m])) by (provider, chain)", + "legendFormat": "{{provider}} - {{chain}}", + "range": true, + "refId": "A" + } + ], + "title": "Quote API Errors (Last 5min)", + "description": "Number of errors per provider and chain", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 95 + }, + { + "color": "red", + "value": 99 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 12, + "x": 12, + "y": 32 + }, + "id": 8, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "100 * sum(rate(quote_api_status_codes_total{status_code=\"200\"}[5m])) by (provider) / sum(rate(quote_api_status_codes_total[5m])) by (provider)", + "legendFormat": "{{provider}}", + "range": true, + "refId": "A" + } + ], + "title": "Quote API Success Rate by Provider", + "description": "Percentage of successful (200) responses", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 38 + }, + "id": 9, + "options": { + "legend": { + "calcs": ["mean", "lastNotNull", "max"], + "displayMode": "table", + "placement": "right", + "showLegend": true, + "sortBy": "Mean", + "sortDesc": false + }, + "tooltip": { + "mode": "multi", + "sort": "asc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.50, sum(rate(quote_api_latency_milliseconds_bucket{provider=\"mobula\",chain!=\"arbitrum\"}[1m])) by (le, chain))", + "legendFormat": "Mobula - {{chain}} (P50)", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.95, sum(rate(quote_api_latency_milliseconds_bucket{provider=\"mobula\",chain!=\"arbitrum\"}[1m])) by (le, chain))", + "legendFormat": "Mobula - {{chain}} (P95)", + "range": true, + "refId": "B" + } + ], + "title": "Mobula Quote API Latency by Chain (P50 & P95)", + "description": "Mobula swap quote latency across supported chains", + "type": "timeseries" + } + ], + "refresh": "5s", + "schemaVersion": 39, + "tags": ["quote-api", "swap", "latency", "mobula", "jupiter", "benchmark"], + "templating": { + "list": [] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Quote API Latency Benchmark", + "uid": "quote_api_latency", + "version": 0, + "weekStart": "" +} diff --git a/harnesses/aggregator-latency-benchmark/grafana/dashboards/head_lag.json b/harnesses/aggregator-latency-benchmark/grafana/dashboards/head_lag.json new file mode 100644 index 00000000..c325ba7c --- /dev/null +++ b/harnesses/aggregator-latency-benchmark/grafana/dashboards/head_lag.json @@ -0,0 +1,945 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Seconds Behind", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 30 + }, + { + "color": "orange", + "value": 60 + }, + { + "color": "red", + "value": 300 + } + ] + }, + "unit": "s" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#1F78B4", + "mode": "fixed" + } + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "#1F78B4", + "value": null + } + ] + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*codex.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#F4D03F", + "mode": "fixed" + } + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "#F4D03F", + "value": null + } + ] + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*geckoterminal.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#27AE60", + "mode": "fixed" + } + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "#27AE60", + "value": null + } + ] + } + } + ] + } + ] + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "calcs": [ + "mean", + "last", + "max", + "min" + ], + "displayMode": "table", + "placement": "right", + "showLegend": true, + "sortBy": "Mean", + "sortDesc": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "avg_over_time(head_lag_seconds[1m])", + "legendFormat": "[{{region}}] {{aggregator}} - {{chain}}", + "range": true, + "refId": "A" + } + ], + "title": "Head Lag (Estimated Seconds Behind)", + "description": "Estimated time in seconds the aggregator is behind the blockchain head. Calculated using average block time per chain.", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "fixed", + "fixedColor": "#1F78B4" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 5 + }, + { + "color": "orange", + "value": 10 + }, + { + "color": "red", + "value": 30 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 18 + }, + "id": 3, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "avg_over_time(head_lag_seconds{aggregator=\"mobula\"}[1m])", + "legendFormat": "[{{region}}] {{chain}}", + "range": true, + "refId": "A" + } + ], + "title": "Mobula - Current Head Lag (Seconds)", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "fixed", + "fixedColor": "#F4D03F" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 5 + }, + { + "color": "orange", + "value": 10 + }, + { + "color": "red", + "value": 30 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 18 + }, + "id": 4, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "avg_over_time(head_lag_seconds{aggregator=\"codex\"}[1m])", + "legendFormat": "[{{region}}] {{chain}}", + "range": true, + "refId": "A" + } + ], + "title": "Codex - Current Head Lag (Seconds)", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "fixed", + "fixedColor": "#27AE60" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 5 + }, + { + "color": "orange", + "value": 10 + }, + { + "color": "red", + "value": 30 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 18 + }, + "id": 5, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "avg_over_time(head_lag_seconds{aggregator=\"geckoterminal\"}[1m])", + "legendFormat": "[{{region}}] {{chain}}", + "range": true, + "refId": "A" + } + ], + "title": "GeckoTerminal - Current Head Lag (Seconds)", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Seconds", + "axisPlacement": "auto", + "drawStyle": "line", + "fillOpacity": 0, + "lineInterpolation": "linear", + "lineWidth": 2, + "showPoints": "auto", + "spanNulls": true + }, + "unit": "s", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 5 + }, + { + "color": "red", + "value": 20 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#1F78B4", + "mode": "fixed" + } + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "#1F78B4", + "value": null + } + ] + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*codex.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#F4D03F", + "mode": "fixed" + } + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "#F4D03F", + "value": null + } + ] + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*geckoterminal.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#27AE60", + "mode": "fixed" + } + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "#27AE60", + "value": null + } + ] + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 10 + }, + "id": 6, + "options": { + "legend": { + "calcs": [ + "lastNotNull" + ], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "avg_over_time(head_lag_seconds{region=\"eu-west\"}[1m])", + "legendFormat": "[{{region}}] {{aggregator}} - {{chain}}", + "refId": "A" + } + ], + "title": "EU West - Head Lag", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Seconds", + "axisPlacement": "auto", + "drawStyle": "line", + "fillOpacity": 0, + "lineInterpolation": "linear", + "lineWidth": 2, + "showPoints": "auto", + "spanNulls": true + }, + "unit": "s", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 5 + }, + { + "color": "red", + "value": 20 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#1F78B4", + "mode": "fixed" + } + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "#1F78B4", + "value": null + } + ] + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*codex.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#F4D03F", + "mode": "fixed" + } + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "#F4D03F", + "value": null + } + ] + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*geckoterminal.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#27AE60", + "mode": "fixed" + } + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "#27AE60", + "value": null + } + ] + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 10 + }, + "id": 7, + "options": { + "legend": { + "calcs": [ + "lastNotNull" + ], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "avg_over_time(head_lag_seconds{region=\"us-east\"}[1m])", + "legendFormat": "[{{region}}] {{aggregator}} - {{chain}}", + "refId": "A" + } + ], + "title": "US East - Head Lag", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Seconds", + "axisPlacement": "auto", + "drawStyle": "line", + "fillOpacity": 0, + "lineInterpolation": "linear", + "lineWidth": 2, + "showPoints": "auto", + "spanNulls": true + }, + "unit": "s", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 5 + }, + { + "color": "red", + "value": 20 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#1F78B4", + "mode": "fixed" + } + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "#1F78B4", + "value": null + } + ] + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*codex.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#F4D03F", + "mode": "fixed" + } + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "#F4D03F", + "value": null + } + ] + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*geckoterminal.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#27AE60", + "mode": "fixed" + } + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "#27AE60", + "value": null + } + ] + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 10 + }, + "id": 8, + "options": { + "legend": { + "calcs": [ + "lastNotNull" + ], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "avg_over_time(head_lag_seconds{region=\"sgp\"}[1m])", + "legendFormat": "[{{region}}] {{aggregator}} - {{chain}}", + "refId": "A" + } + ], + "title": "Singapore - Head Lag", + "type": "timeseries" + } + ], + "refresh": "30s", + "schemaVersion": 39, + "tags": [ + "head-lag", + "indexation", + "blockchain", + "sync" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Head Lag Monitor - Blockchain vs Aggregator Sync", + "uid": "head_lag_monitor", + "version": 0, + "weekStart": "" +} \ No newline at end of file diff --git a/harnesses/aggregator-latency-benchmark/grafana/dashboards/metadata_coverage.json b/harnesses/aggregator-latency-benchmark/grafana/dashboards/metadata_coverage.json new file mode 100644 index 00000000..b4c7b305 --- /dev/null +++ b/harnesses/aggregator-latency-benchmark/grafana/dashboards/metadata_coverage.json @@ -0,0 +1,1133 @@ +{ + "annotations": { + "list": [] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "percent" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#1F78B4", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*codex.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#F4D03F", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*jupiter.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#27AE60", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "barRadius": 0.1, + "barWidth": 0.8, + "fullHighlight": false, + "groupWidth": 0.7, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "right", + "showLegend": true + }, + "orientation": "horizontal", + "showValue": "always", + "stacking": "none", + "tooltip": { + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 0 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "exemplar": false, + "expr": "(sum(metadata_coverage_success_total{field=\"logo\"}) by (provider) / sum(metadata_coverage_checks_total{field=\"logo\"}) by (provider)) * 100", + "format": "time_series", + "instant": true, + "legendFormat": "{{provider}} - Logo", + "range": false, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "exemplar": false, + "expr": "(sum(metadata_coverage_success_total{field=\"description\"}) by (provider) / sum(metadata_coverage_checks_total{field=\"description\"}) by (provider)) * 100", + "format": "time_series", + "instant": true, + "legendFormat": "{{provider}} - Description", + "range": false, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "exemplar": false, + "expr": "(sum(metadata_coverage_success_total{field=\"twitter\"}) by (provider) / sum(metadata_coverage_checks_total{field=\"twitter\"}) by (provider)) * 100", + "format": "time_series", + "instant": true, + "legendFormat": "{{provider}} - Twitter", + "range": false, + "refId": "C" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "exemplar": false, + "expr": "(sum(metadata_coverage_success_total{field=\"website\"}) by (provider) / sum(metadata_coverage_checks_total{field=\"website\"}) by (provider)) * 100", + "format": "time_series", + "instant": true, + "legendFormat": "{{provider}} - Website", + "range": false, + "refId": "D" + } + ], + "title": "Metadata Coverage Comparison: Mobula vs Codex vs Jupiter (%)", + "description": "Percentage of new tokens with each metadata field present", + "type": "barchart" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "yellow", + "value": 30 + }, + { + "color": "green", + "value": 70 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 4, + "x": 0, + "y": 8 + }, + "id": 2, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "(sum(metadata_coverage_success_total{provider=\"mobula\", field=\"logo\"}) / sum(metadata_coverage_checks_total{provider=\"mobula\", field=\"logo\"})) * 100", + "instant": true, + "legendFormat": "Logo", + "refId": "A" + } + ], + "title": "Mobula - Logo %", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "yellow", + "value": 30 + }, + { + "color": "green", + "value": 70 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 4, + "x": 4, + "y": 8 + }, + "id": 3, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "(sum(metadata_coverage_success_total{provider=\"codex\", field=\"logo\"}) / sum(metadata_coverage_checks_total{provider=\"codex\", field=\"logo\"})) * 100", + "instant": true, + "legendFormat": "Logo", + "refId": "A" + } + ], + "title": "Codex - Logo %", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "yellow", + "value": 30 + }, + { + "color": "green", + "value": 70 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 4, + "x": 8, + "y": 8 + }, + "id": 11, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "(sum(metadata_coverage_success_total{provider=\"jupiter\", field=\"logo\"}) / sum(metadata_coverage_checks_total{provider=\"jupiter\", field=\"logo\"})) * 100", + "instant": true, + "legendFormat": "Logo", + "refId": "A" + } + ], + "title": "Jupiter - Logo % (Solana)", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "yellow", + "value": 30 + }, + { + "color": "green", + "value": 70 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 4, + "x": 12, + "y": 8 + }, + "id": 4, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "(sum(metadata_coverage_success_total{provider=\"mobula\", field=\"description\"}) / sum(metadata_coverage_checks_total{provider=\"mobula\", field=\"description\"})) * 100", + "instant": true, + "legendFormat": "Desc", + "refId": "A" + } + ], + "title": "Mobula - Description %", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "yellow", + "value": 30 + }, + { + "color": "green", + "value": 70 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 4, + "x": 16, + "y": 8 + }, + "id": 5, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "(sum(metadata_coverage_success_total{provider=\"codex\", field=\"description\"}) / sum(metadata_coverage_checks_total{provider=\"codex\", field=\"description\"})) * 100", + "instant": true, + "legendFormat": "Desc", + "refId": "A" + } + ], + "title": "Codex - Description %", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "percent" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#1F78B4", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*codex.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#F4D03F", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*jupiter.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#27AE60", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 13 + }, + "id": 6, + "options": { + "legend": { + "calcs": [ + "mean", + "lastNotNull" + ], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "(sum(metadata_coverage_success_total{field=\"logo\"}) by (provider) / sum(metadata_coverage_checks_total{field=\"logo\"}) by (provider)) * 100", + "legendFormat": "{{provider}}", + "range": true, + "refId": "A" + } + ], + "title": "Logo Coverage Over Time", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "percent" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#1F78B4", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*codex.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#F4D03F", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*jupiter.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#27AE60", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 13 + }, + "id": 7, + "options": { + "legend": { + "calcs": [ + "mean", + "lastNotNull" + ], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "(sum(metadata_coverage_success_total{field=\"description\"}) by (provider) / sum(metadata_coverage_checks_total{field=\"description\"}) by (provider)) * 100", + "legendFormat": "{{provider}}", + "range": true, + "refId": "A" + } + ], + "title": "Description Coverage Over Time", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "ms" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#1F78B4", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*codex.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#F4D03F", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*jupiter.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#27AE60", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 21 + }, + "id": 8, + "options": { + "legend": { + "calcs": [ + "mean", + "lastNotNull", + "max" + ], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.50, sum(rate(metadata_api_latency_milliseconds_bucket[5m])) by (le, provider))", + "legendFormat": "{{provider}} (P50)", + "range": true, + "refId": "A" + } + ], + "title": "Metadata API Latency (P50)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 8, + "x": 0, + "y": 29 + }, + "id": 9, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(metadata_coverage_checks_total{field=\"logo\"}) by (provider)", + "instant": true, + "legendFormat": "{{provider}}", + "refId": "A" + } + ], + "title": "Total Tokens Checked by Provider", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 16, + "x": 8, + "y": 29 + }, + "id": 10, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(metadata_coverage_checks_total{field=\"logo\"}) by (chain)", + "instant": true, + "legendFormat": "{{chain}}", + "refId": "A" + } + ], + "title": "Tokens Checked by Chain", + "type": "stat" + } + ], + "refresh": "30s", + "schemaVersion": 39, + "tags": [ + "metadata", + "coverage", + "logo", + "benchmark", + "mobula", + "codex", + "jupiter" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Metadata Coverage Benchmark", + "uid": "metadata_coverage_benchmark", + "version": 0, + "weekStart": "" +} \ No newline at end of file diff --git a/harnesses/aggregator-latency-benchmark/grafana/grafana-entrypoint.sh b/harnesses/aggregator-latency-benchmark/grafana/grafana-entrypoint.sh new file mode 100755 index 00000000..ccf63ca7 --- /dev/null +++ b/harnesses/aggregator-latency-benchmark/grafana/grafana-entrypoint.sh @@ -0,0 +1,29 @@ +#!/bin/sh + +echo "=== GRAFANA ENTRYPOINT SCRIPT STARTING ===" +echo "Working directory: $(pwd)" +echo "User: $(whoami)" + +# Create dashboards directory +mkdir -p /var/lib/grafana/dashboards + +# Debug: print environment variables +echo "Checking environment..." +env | grep -E '(RAILWAY|HIDE_QUOTE)' || echo "No RAILWAY/HIDE_QUOTE variables found" + +# Copy dashboards from source +if [ "$HIDE_QUOTE_DASHBOARD" = "true" ]; then + echo "HIDE_QUOTE_DASHBOARD=true - hiding Quote API Latency Benchmark dashboard" + cp /dashboards-source/head_lag.json /var/lib/grafana/dashboards/ +else + echo "Copying all dashboards" + cp /dashboards-source/*.json /var/lib/grafana/dashboards/ +fi + +echo "Dashboards copied:" +ls -la /var/lib/grafana/dashboards/ + +echo "=== GRAFANA ENTRYPOINT SCRIPT COMPLETE ===" + +# Start Grafana with default entrypoint +exec /run.sh diff --git a/harnesses/aggregator-latency-benchmark/grafana/provisioning/dashboards/dashboard.yml b/harnesses/aggregator-latency-benchmark/grafana/provisioning/dashboards/dashboard.yml new file mode 100644 index 00000000..332c8f34 --- /dev/null +++ b/harnesses/aggregator-latency-benchmark/grafana/provisioning/dashboards/dashboard.yml @@ -0,0 +1,13 @@ +apiVersion: 1 + +providers: + - name: 'Aggregator Latency Dashboards' + orgId: 1 + folder: '' + type: file + disableDeletion: false + updateIntervalSeconds: 10 + allowUiUpdates: false + options: + path: /var/lib/grafana/dashboards + foldersFromFilesStructure: true diff --git a/harnesses/aggregator-latency-benchmark/grafana/provisioning/datasources/prometheus.yml b/harnesses/aggregator-latency-benchmark/grafana/provisioning/datasources/prometheus.yml new file mode 100644 index 00000000..30d5cc21 --- /dev/null +++ b/harnesses/aggregator-latency-benchmark/grafana/provisioning/datasources/prometheus.yml @@ -0,0 +1,10 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: ${PROMETHEUS_URL} + uid: prometheus + isDefault: true + editable: false diff --git a/harnesses/aggregator-latency-benchmark/list_spikes.sh b/harnesses/aggregator-latency-benchmark/list_spikes.sh new file mode 100755 index 00000000..99d5d554 --- /dev/null +++ b/harnesses/aggregator-latency-benchmark/list_spikes.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +PROM_URL="https://prometheus-production-0859.up.railway.app" +THRESHOLD=${1:-5} +HOURS=${2:-24} + +# Période à scanner +END_TS=$(date +%s) +START_TS=$((END_TS - HOURS * 3600)) + +echo "=== SPIKES > ${THRESHOLD}s (last ${HOURS}h) ===" +echo "" + +QUERY='head_lag_seconds{aggregator="mobula"}' +ENCODED_QUERY=$(printf %s "$QUERY" | jq -sRr @uri) + +# Fetch avec step plus large pour moins de données +curl -s "${PROM_URL}/api/v1/query_range?query=${ENCODED_QUERY}&start=${START_TS}&end=${END_TS}&step=15" | \ +jq -r --argjson threshold "$THRESHOLD" ' + .data.result[]? | + .metric as $m | + (.values // [])[] | + (.[1] | tonumber) as $val | + select($val > $threshold) | + "\(.[0])|\($m.region // "unknown")|\($m.chain // "unknown")|\($val)" +' | sort -t'|' -k1 -n | while IFS='|' read -r ts region chain val; do + DATE=$(date -r "$ts" '+%Y-%m-%d %H:%M:%S') + printf "%-19s | [%-8s] mobula - %-8s | %.2fs\n" "$DATE" "$region" "$chain" "$val" +done | tee /tmp/spikes_list.txt + +echo "" +COUNT=$(wc -l < /tmp/spikes_list.txt | tr -d ' ') +echo "Total: $COUNT spikes found" +echo "" +echo "Usage: $0 [threshold] [hours]" diff --git a/harnesses/aggregator-latency-benchmark/monitoring/alert_rules.yml b/harnesses/aggregator-latency-benchmark/monitoring/alert_rules.yml new file mode 100644 index 00000000..e08b8a73 --- /dev/null +++ b/harnesses/aggregator-latency-benchmark/monitoring/alert_rules.yml @@ -0,0 +1,127 @@ +groups: + - name: aggregator_latency_alerts + interval: 30s + rules: + # Missing metrics alerts + - alert: MissingMobulaMetrics + expr: absent(rest_api_latency_milliseconds_count{aggregator="mobula"}) + for: 5m + labels: + severity: critical + aggregator: mobula + alert_type: missing_metrics + annotations: + summary: "Mobula metrics are missing" + description: "No REST API metrics received from Mobula for 5 minutes. Check API key and monitor status." + + # Stale metrics alerts (>20 minutes without update) + - alert: MobulaEthereumStaleMetrics + expr: (time() - timestamp(rest_api_latency_milliseconds_count{aggregator="mobula",chain="ethereum"})) > 1200 + for: 2m + labels: + severity: warning + aggregator: mobula + chain: ethereum + alert_type: stale_metrics + annotations: + summary: "Mobula Ethereum metrics are stale" + description: "Mobula Ethereum REST API hasn't responded in over 20 minutes. Check API connectivity." + + - alert: MobulaBaseStaleMetrics + expr: (time() - timestamp(rest_api_latency_milliseconds_count{aggregator="mobula",chain="base"})) > 1200 + for: 2m + labels: + severity: warning + aggregator: mobula + chain: base + alert_type: stale_metrics + annotations: + summary: "Mobula Base metrics are stale" + description: "Mobula Base REST API hasn't responded in over 20 minutes. Check API connectivity." + + # Latency spike alerts + - alert: MobulaEthereumLatencySpike + expr: | + ( + rate(rest_api_latency_milliseconds_sum{aggregator="mobula",chain="ethereum"}[5m]) / + rate(rest_api_latency_milliseconds_count{aggregator="mobula",chain="ethereum"}[5m]) + ) > 500 + for: 3m + labels: + severity: warning + aggregator: mobula + chain: ethereum + alert_type: latency_spike + annotations: + summary: "Mobula Ethereum latency spike" + description: "Mobula Ethereum average latency is {{ $value | humanize }}ms over the last 5 minutes (threshold: 500ms)" + + - alert: MobulaBaseLatencySpike + expr: | + ( + rate(rest_api_latency_milliseconds_sum{aggregator="mobula",chain="base"}[5m]) / + rate(rest_api_latency_milliseconds_count{aggregator="mobula",chain="base"}[5m]) + ) > 500 + for: 3m + labels: + severity: warning + aggregator: mobula + chain: base + alert_type: latency_spike + annotations: + summary: "Mobula Base latency spike" + description: "Mobula Base average latency is {{ $value | humanize }}ms over the last 5 minutes (threshold: 500ms)" + + # Instant spike detection for Mobula (any single request >10s) + - alert: MobulaInstantLatencySpike + expr: | + rest_api_latency_milliseconds{aggregator="mobula"} > 10000 + labels: + severity: warning + aggregator: mobula + alert_type: instant_spike + annotations: + summary: "Mobula instant latency spike on {{ $labels.chain }}" + description: "Mobula {{ $labels.chain }} single request took {{ $value | humanize }}ms (>10s). Brief spike detected." + + # Extreme latency spikes (>5x normal) + - alert: MobulaExtremeLatencySpike + expr: | + ( + rate(rest_api_latency_milliseconds_sum{aggregator="mobula"}[5m]) / + rate(rest_api_latency_milliseconds_count{aggregator="mobula"}[5m]) + ) > 1000 + for: 1m + labels: + severity: critical + aggregator: mobula + alert_type: extreme_latency + annotations: + summary: "Mobula EXTREME latency spike on {{ $labels.chain }}" + description: "Mobula {{ $labels.chain }} latency is {{ $value | humanize }}ms (>1000ms). Possible service degradation." + + # Error rate alerts + - alert: HighRESTErrorRate + expr: | + ( + rate(rest_api_errors_total[5m]) / + (rate(rest_api_errors_total[5m]) + rate(rest_api_latency_milliseconds_count[5m])) + ) > 0.1 + for: 5m + labels: + severity: warning + alert_type: high_error_rate + annotations: + summary: "High REST API error rate for {{ $labels.aggregator }} {{ $labels.chain }}" + description: "Error rate is {{ $value | humanizePercentage }} over the last 5 minutes (threshold: 10%)" + + # Service availability + - alert: CodexServiceDown + expr: up{job="latency_monitor"} == 0 + for: 2m + labels: + severity: critical + alert_type: service_down + annotations: + summary: "Latency monitor service is down" + description: "The aggregator latency monitor has been down for 2 minutes. No metrics are being collected." diff --git a/harnesses/aggregator-latency-benchmark/monitoring/alertmanager.yml b/harnesses/aggregator-latency-benchmark/monitoring/alertmanager.yml new file mode 100644 index 00000000..77b9e263 --- /dev/null +++ b/harnesses/aggregator-latency-benchmark/monitoring/alertmanager.yml @@ -0,0 +1,36 @@ +global: + resolve_timeout: 5m + +route: + group_by: ['alertname', 'chain', 'aggregator'] + group_wait: 10s + group_interval: 30s + repeat_interval: 4h + receiver: 'slack-webhook' + +receivers: + - name: 'slack-webhook' + webhook_configs: + - url: 'https://agent-slack-production.up.railway.app/webhook/grafana' + send_resolved: true + +inhibit_rules: + # Inhibit warning alerts if critical alert is firing + - source_match: + severity: 'critical' + target_match: + severity: 'warning' + equal: ['alertname', 'chain', 'aggregator'] + + # Inhibit stale metrics alerts if service is down + - source_match: + alert_type: 'service_down' + target_match: + alert_type: 'stale_metrics' + + # Inhibit stale metrics if missing metrics alert is firing + - source_match: + alert_type: 'missing_metrics' + target_match: + alert_type: 'stale_metrics' + equal: ['aggregator'] diff --git a/harnesses/aggregator-latency-benchmark/monitoring/grafana/dashboards/disabled/quote_api_latency.json b/harnesses/aggregator-latency-benchmark/monitoring/grafana/dashboards/disabled/quote_api_latency.json new file mode 100644 index 00000000..16392e30 --- /dev/null +++ b/harnesses/aggregator-latency-benchmark/monitoring/grafana/dashboards/disabled/quote_api_latency.json @@ -0,0 +1,819 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 3, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 500 + }, + { + "color": "red", + "value": 2000 + } + ] + }, + "unit": "ms" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "orange", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*jupiter.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*kyberswap.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "blue", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*lifi.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "purple", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*paraswap.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "yellow", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*openocean.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": ["mean", "lastNotNull", "max", "min"], + "displayMode": "table", + "placement": "right", + "showLegend": true, + "sortBy": "Mean", + "sortDesc": false + }, + "tooltip": { + "mode": "multi", + "sort": "asc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.50, sum(rate(quote_api_latency_milliseconds_bucket{chain!=\"arbitrum\"}[1m])) by (le, provider, chain))", + "legendFormat": "{{provider}} - {{chain}} (P50)", + "range": true, + "refId": "A" + } + ], + "title": "Quote API Latency Comparison - All Providers (P50)", + "description": "Median Quote API response time - Mobula vs competitors (30s polling interval)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 300 + }, + { + "color": "orange", + "value": 700 + }, + { + "color": "red", + "value": 1500 + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 24, + "x": 0, + "y": 10 + }, + "id": 2, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.50, sum(rate(quote_api_latency_milliseconds_bucket[5m])) by (le, provider))", + "legendFormat": "{{provider}}", + "range": true, + "refId": "A" + } + ], + "title": "Latest Quote API Latency (P50) by Provider", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 3, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 1000 + } + ] + }, + "unit": "ms" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "orange", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*jupiter.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 3, + "options": { + "legend": { + "calcs": ["mean", "lastNotNull", "max"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "asc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.50, sum(rate(quote_api_latency_milliseconds_bucket{chain=\"solana\"}[1m])) by (le, provider))", + "legendFormat": "{{provider}} (P50)", + "range": true, + "refId": "A" + } + ], + "title": "\ud83c\udf1e Solana Quote APIs - Mobula vs Jupiter", + "description": "Solana swap quote latency comparison", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 3, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 1000 + } + ] + }, + "unit": "ms" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "orange", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 16 + }, + "id": 4, + "options": { + "legend": { + "calcs": ["mean", "lastNotNull", "max"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "asc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.50, sum(rate(quote_api_latency_milliseconds_bucket{chain=\"base\"}[1m])) by (le, provider))", + "legendFormat": "{{provider}} (P50)", + "range": true, + "refId": "A" + } + ], + "title": "\ud83d\udd35 Base Quote APIs - Mobula vs Competitors", + "description": "Base chain swap quote latency comparison", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "bars", + "fillOpacity": 80, + "gradientMode": "hue", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 12, + "x": 0, + "y": 32 + }, + "id": 7, + "options": { + "legend": { + "calcs": ["sum"], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(increase(quote_api_errors_total{chain!=\"arbitrum\"}[5m])) by (provider, chain)", + "legendFormat": "{{provider}} - {{chain}}", + "range": true, + "refId": "A" + } + ], + "title": "Quote API Errors (Last 5min)", + "description": "Number of errors per provider and chain", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 95 + }, + { + "color": "red", + "value": 99 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 12, + "x": 12, + "y": 32 + }, + "id": 8, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "100 * sum(rate(quote_api_status_codes_total{status_code=\"200\"}[5m])) by (provider) / sum(rate(quote_api_status_codes_total[5m])) by (provider)", + "legendFormat": "{{provider}}", + "range": true, + "refId": "A" + } + ], + "title": "Quote API Success Rate by Provider", + "description": "Percentage of successful (200) responses", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 38 + }, + "id": 9, + "options": { + "legend": { + "calcs": ["mean", "lastNotNull", "max"], + "displayMode": "table", + "placement": "right", + "showLegend": true, + "sortBy": "Mean", + "sortDesc": false + }, + "tooltip": { + "mode": "multi", + "sort": "asc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.50, sum(rate(quote_api_latency_milliseconds_bucket{provider=\"mobula\",chain!=\"arbitrum\"}[1m])) by (le, chain))", + "legendFormat": "Mobula - {{chain}} (P50)", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.95, sum(rate(quote_api_latency_milliseconds_bucket{provider=\"mobula\",chain!=\"arbitrum\"}[1m])) by (le, chain))", + "legendFormat": "Mobula - {{chain}} (P95)", + "range": true, + "refId": "B" + } + ], + "title": "Mobula Quote API Latency by Chain (P50 & P95)", + "description": "Mobula swap quote latency across supported chains", + "type": "timeseries" + } + ], + "refresh": "5s", + "schemaVersion": 39, + "tags": ["quote-api", "swap", "latency", "mobula", "jupiter", "benchmark"], + "templating": { + "list": [] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Quote API Latency Benchmark", + "uid": "quote_api_latency", + "version": 0, + "weekStart": "" +} diff --git a/harnesses/aggregator-latency-benchmark/monitoring/grafana/dashboards/head_lag.json b/harnesses/aggregator-latency-benchmark/monitoring/grafana/dashboards/head_lag.json new file mode 100644 index 00000000..02606a47 --- /dev/null +++ b/harnesses/aggregator-latency-benchmark/monitoring/grafana/dashboards/head_lag.json @@ -0,0 +1,773 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Seconds Behind", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "line" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 30 + }, + { + "color": "orange", + "value": 60 + }, + { + "color": "red", + "value": 300 + } + ] + }, + "unit": "s" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "orange", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*codex.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*geckoterminal.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "purple", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "calcs": ["median", "lastNotNull", "max", "min"], + "displayMode": "table", + "placement": "right", + "showLegend": true, + "sortBy": "Median", + "sortDesc": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "head_lag_seconds{chain!=\"ethereum\"}", + "legendFormat": "{{aggregator}} - {{chain}}", + "range": true, + "refId": "A" + } + ], + "title": "Head Lag (Estimated Seconds Behind)", + "description": "Estimated time in seconds the aggregator is behind the blockchain head. Calculated using average block time per chain.", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 5 + }, + { + "color": "orange", + "value": 10 + }, + { + "color": "red", + "value": 30 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 18 + }, + "id": 3, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "head_lag_seconds{aggregator=\"mobula\",chain!=\"ethereum\"}", + "legendFormat": "{{chain}}", + "range": true, + "refId": "A" + } + ], + "title": "Mobula - Current Head Lag (Seconds)", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 5 + }, + { + "color": "orange", + "value": 10 + }, + { + "color": "red", + "value": 30 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 18 + }, + "id": 4, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "head_lag_seconds{aggregator=\"codex\",chain!=\"ethereum\"}", + "legendFormat": "{{chain}}", + "range": true, + "refId": "A" + } + ], + "title": "Codex - Current Head Lag (Seconds)", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 5 + }, + { + "color": "orange", + "value": 10 + }, + { + "color": "red", + "value": 30 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 18 + }, + "id": 5, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "head_lag_seconds{aggregator=\"geckoterminal\",chain!=\"ethereum\"}", + "legendFormat": "{{chain}}", + "range": true, + "refId": "A" + } + ], + "title": "GeckoTerminal - Current Head Lag (Seconds)", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Seconds", + "axisPlacement": "auto", + "drawStyle": "line", + "fillOpacity": 10, + "lineInterpolation": "linear", + "lineWidth": 2, + "showPoints": "auto" + }, + "unit": "s", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 5 + }, + { + "color": "red", + "value": 20 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "orange", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*codex.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*geckoterminal.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "purple", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 10 + }, + "id": 6, + "options": { + "legend": { + "calcs": ["lastNotNull"], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "head_lag_seconds{region=\"eu-west\",chain!=\"ethereum\"}", + "legendFormat": "{{aggregator}} - {{chain}}", + "refId": "A" + } + ], + "title": "EU West - Head Lag", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Seconds", + "axisPlacement": "auto", + "drawStyle": "line", + "fillOpacity": 10, + "lineInterpolation": "linear", + "lineWidth": 2, + "showPoints": "auto" + }, + "unit": "s", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 5 + }, + { + "color": "red", + "value": 20 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "orange", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*codex.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*geckoterminal.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "purple", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 10 + }, + "id": 7, + "options": { + "legend": { + "calcs": ["lastNotNull"], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "head_lag_seconds{region=\"us-west\",chain!=\"ethereum\"}", + "legendFormat": "{{aggregator}} - {{chain}}", + "refId": "A" + } + ], + "title": "US West - Head Lag", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Seconds", + "axisPlacement": "auto", + "drawStyle": "line", + "fillOpacity": 10, + "lineInterpolation": "linear", + "lineWidth": 2, + "showPoints": "auto" + }, + "unit": "s", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 5 + }, + { + "color": "red", + "value": 20 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "orange", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*codex.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*geckoterminal.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "purple", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 10 + }, + "id": 8, + "options": { + "legend": { + "calcs": ["lastNotNull"], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "head_lag_seconds{region=\"singapore\",chain!=\"ethereum\"}", + "legendFormat": "{{aggregator}} - {{chain}}", + "refId": "A" + } + ], + "title": "Singapore - Head Lag", + "type": "timeseries" + } + ], + "refresh": "30s", + "schemaVersion": 39, + "tags": ["head-lag", "indexation", "blockchain", "sync"], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Head Lag Monitor - Blockchain vs Aggregator Sync", + "uid": "head_lag_monitor", + "version": 0, + "weekStart": "" +} diff --git a/harnesses/aggregator-latency-benchmark/monitoring/grafana/provisioning/dashboards/dashboard.yml b/harnesses/aggregator-latency-benchmark/monitoring/grafana/provisioning/dashboards/dashboard.yml new file mode 100644 index 00000000..49b448da --- /dev/null +++ b/harnesses/aggregator-latency-benchmark/monitoring/grafana/provisioning/dashboards/dashboard.yml @@ -0,0 +1,13 @@ +apiVersion: 1 + +providers: + - name: 'Aggregator Latency Dashboards' + orgId: 1 + folder: '' + type: file + disableDeletion: false + updateIntervalSeconds: 10 + allowUiUpdates: true + options: + path: /var/lib/grafana/dashboards + foldersFromFilesStructure: true diff --git a/harnesses/aggregator-latency-benchmark/monitoring/grafana/provisioning/datasources/prometheus.yml b/harnesses/aggregator-latency-benchmark/monitoring/grafana/provisioning/datasources/prometheus.yml new file mode 100644 index 00000000..40a18e69 --- /dev/null +++ b/harnesses/aggregator-latency-benchmark/monitoring/grafana/provisioning/datasources/prometheus.yml @@ -0,0 +1,10 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: ${PROMETHEUS_URL:-http://prometheus:9090} + uid: prometheus + isDefault: true + editable: false diff --git a/harnesses/aggregator-latency-benchmark/monitoring/prometheus.yml b/harnesses/aggregator-latency-benchmark/monitoring/prometheus.yml new file mode 100644 index 00000000..bd7cf041 --- /dev/null +++ b/harnesses/aggregator-latency-benchmark/monitoring/prometheus.yml @@ -0,0 +1,21 @@ +global: + scrape_interval: 5s + evaluation_interval: 5s + +# Load alert rules +rule_files: + - '/etc/prometheus/alert_rules.yml' + +# Alertmanager configuration +alerting: + alertmanagers: + - static_configs: + - targets: ['alertmanager:9093'] + +scrape_configs: + - job_name: 'latency_monitor' + static_configs: + - targets: ['monitor:2112'] + labels: + app: 'aggregator_latency_monitor' + environment: 'production' diff --git a/harnesses/aggregator-latency-benchmark/prometheus/Dockerfile b/harnesses/aggregator-latency-benchmark/prometheus/Dockerfile new file mode 100644 index 00000000..a422384f --- /dev/null +++ b/harnesses/aggregator-latency-benchmark/prometheus/Dockerfile @@ -0,0 +1,14 @@ +FROM prom/prometheus:v2.49.1 + +USER root + +# Pick which prometheus.yml to bake in (prometheus.yml for prod, prometheus.staging.yml for staging). +ARG PROMETHEUS_CONFIG=prometheus.yml + +COPY ${PROMETHEUS_CONFIG} /etc/prometheus/prometheus.yml +COPY alert_rules.yml /etc/prometheus/alert_rules.yml + +EXPOSE 9090 + +# --enable-feature=expand-external-labels: lets global.external_labels read ${ENVIRONMENT} from env +CMD ["--config.file=/etc/prometheus/prometheus.yml", "--storage.tsdb.path=/prometheus", "--web.enable-lifecycle", "--web.enable-admin-api", "--enable-feature=expand-external-labels"] diff --git a/harnesses/aggregator-latency-benchmark/prometheus/alert_rules.yml b/harnesses/aggregator-latency-benchmark/prometheus/alert_rules.yml new file mode 100644 index 00000000..dd89ab5e --- /dev/null +++ b/harnesses/aggregator-latency-benchmark/prometheus/alert_rules.yml @@ -0,0 +1,223 @@ +groups: + - name: aggregator_latency_alerts + interval: 30s + rules: + # Missing metrics alerts + - alert: MissingMobulaMetrics + expr: absent(rest_api_latency_milliseconds_count{aggregator="mobula"}) + for: 5m + labels: + severity: critical + aggregator: mobula + alert_type: missing_metrics + app: aggregator_latency_monitor + annotations: + summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}Mobula metrics are missing' + description: "No REST API metrics received from Mobula for 5 minutes. Check API key and monitor status." + + # Stale metrics alerts (>20 minutes without update) + - alert: MobulaBaseStaleMetrics + expr: (time() - timestamp(rest_api_latency_milliseconds_count{aggregator="mobula",chain="base"})) > 1200 + for: 2m + labels: + severity: warning + aggregator: mobula + chain: base + alert_type: stale_metrics + app: aggregator_latency_monitor + annotations: + summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}Mobula Base metrics are stale' + description: "Mobula Base REST API hasn't responded in over 20 minutes. Check API connectivity." + + # Latency spike alerts + - alert: MobulaSolanaLatencySpike + expr: | + ( + rate(rest_api_latency_milliseconds_sum{aggregator="mobula",chain="solana"}[5m]) / + rate(rest_api_latency_milliseconds_count{aggregator="mobula",chain="solana"}[5m]) + ) > 2000 + for: 3m + labels: + severity: warning + aggregator: mobula + chain: solana + alert_type: latency_spike + app: aggregator_latency_monitor + annotations: + summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}Mobula Solana latency spike' + description: "Mobula Solana average latency is {{ $value | humanize }}ms over the last 5 minutes (threshold: 2000ms)" + + - alert: MobulaBaseLatencySpike + expr: | + ( + rate(rest_api_latency_milliseconds_sum{aggregator="mobula",chain="base"}[5m]) / + rate(rest_api_latency_milliseconds_count{aggregator="mobula",chain="base"}[5m]) + ) > 2500 + for: 3m + labels: + severity: warning + aggregator: mobula + chain: base + alert_type: latency_spike + app: aggregator_latency_monitor + annotations: + summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}Mobula Base latency spike' + description: "Mobula Base average latency is {{ $value | humanize }}ms over the last 5 minutes (threshold: 2500ms)" + + - alert: MobulaBNBLatencySpike + expr: | + ( + rate(rest_api_latency_milliseconds_sum{aggregator="mobula",chain="bnb"}[5m]) / + rate(rest_api_latency_milliseconds_count{aggregator="mobula",chain="bnb"}[5m]) + ) > 2500 + for: 3m + labels: + severity: warning + aggregator: mobula + chain: bnb + alert_type: latency_spike + app: aggregator_latency_monitor + annotations: + summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}Mobula BNB latency spike' + description: "Mobula BNB average latency is {{ $value | humanize }}ms over the last 5 minutes (threshold: 2500ms)" + + # Instant spike detection for Mobula (any single request >10s) + - alert: MobulaInstantLatencySpike + expr: | + rest_api_latency_milliseconds{aggregator="mobula"} > 10000 + labels: + severity: warning + aggregator: mobula + alert_type: instant_spike + app: aggregator_latency_monitor + annotations: + summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}Mobula instant latency spike on {{ $labels.chain }}' + description: "Mobula {{ $labels.chain }} single request took {{ $value | humanize }}ms (>10s). Brief spike detected." + + # Error rate alerts + - alert: HighRESTErrorRate + expr: | + ( + rate(rest_api_errors_total[5m]) / + (rate(rest_api_errors_total[5m]) + rate(rest_api_latency_milliseconds_count[5m])) + ) > 0.1 + for: 5m + labels: + severity: warning + alert_type: high_error_rate + app: aggregator_latency_monitor + annotations: + summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}High REST API error rate for {{ $labels.aggregator }} {{ $labels.chain }}' + description: "Error rate is {{ $value | humanizePercentage }} over the last 5 minutes (threshold: 10%)" + + # Head lag (WebSocket latency) alerts - per-chain thresholds + # NOTE: alerts use fixed-cardinality gauges now (no tx_hash label) to avoid stale-series spam. + # Sustained 30s window avoids single-spike firings (reconnect bursts, transient network jitter). + - alert: MobulaHeadLagSpikeBase + expr: mobula_head_lag_detailed_seconds{chain="base"} > 2.5 + for: 30s + labels: + severity: warning + aggregator: mobula + alert_type: head_lag_spike + app: aggregator_latency_monitor + chain: base + annotations: + summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}Mobula head lag spike on Base' + description: | + **Mobula WebSocket Latency Spike - Base** + + **Latency:** {{ $value }}s (threshold: 2.5s, sustained ≥30s) + • Mobula Processing: {{ with query (printf "mobula_processing_lag_seconds{chain=\"base\",region=\"%s\",pool_address=\"%s\"}" $labels.region $labels.pool_address) }}{{ . | first | value }}s{{ end }} + • Network: {{ with query (printf "mobula_network_lag_seconds{chain=\"base\",region=\"%s\",pool_address=\"%s\"}" $labels.region $labels.pool_address) }}{{ . | first | value }}s{{ end }} + + • Pool: {{ $labels.pool_address }} + • Region: {{ $labels.region }} + • Latest tx: {{ with query (printf "mobula_last_tx_hash{chain=\"base\",region=\"%s\",pool_address=\"%s\"}" $labels.region $labels.pool_address) }}https://basescan.org/tx/{{ . | first | label "tx_hash" }}{{ end }} + + - alert: MobulaHeadLagSpikeSolana + expr: mobula_head_lag_detailed_seconds{chain="solana"} > 2 + for: 30s + labels: + severity: warning + aggregator: mobula + alert_type: head_lag_spike + app: aggregator_latency_monitor + chain: solana + annotations: + summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}Mobula head lag spike on Solana' + description: | + **Mobula WebSocket Latency Spike - Solana** + + **Latency:** {{ $value }}s (threshold: 2s, sustained ≥30s) + • Mobula Processing: {{ with query (printf "mobula_processing_lag_seconds{chain=\"solana\",region=\"%s\",pool_address=\"%s\"}" $labels.region $labels.pool_address) }}{{ . | first | value }}s{{ end }} + • Network: {{ with query (printf "mobula_network_lag_seconds{chain=\"solana\",region=\"%s\",pool_address=\"%s\"}" $labels.region $labels.pool_address) }}{{ . | first | value }}s{{ end }} + + • Pool: {{ $labels.pool_address }} + • Region: {{ $labels.region }} + • Latest tx: {{ with query (printf "mobula_last_tx_hash{chain=\"solana\",region=\"%s\",pool_address=\"%s\"}" $labels.region $labels.pool_address) }}https://solscan.io/tx/{{ . | first | label "tx_hash" }}{{ end }} + + - alert: MobulaHeadLagSpikeBNB + expr: mobula_head_lag_detailed_seconds{chain="bnb"} > 2.5 + for: 30s + labels: + severity: warning + aggregator: mobula + alert_type: head_lag_spike + app: aggregator_latency_monitor + chain: bnb + annotations: + summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}Mobula head lag spike on BNB Chain' + description: | + **Mobula WebSocket Latency Spike - BNB Chain** + + **Latency:** {{ $value }}s (threshold: 2.5s, sustained ≥30s) + • Mobula Processing: {{ with query (printf "mobula_processing_lag_seconds{chain=\"bnb\",region=\"%s\",pool_address=\"%s\"}" $labels.region $labels.pool_address) }}{{ . | first | value }}s{{ end }} + • Network: {{ with query (printf "mobula_network_lag_seconds{chain=\"bnb\",region=\"%s\",pool_address=\"%s\"}" $labels.region $labels.pool_address) }}{{ . | first | value }}s{{ end }} + + • Pool: {{ $labels.pool_address }} + • Region: {{ $labels.region }} + • Latest tx: {{ with query (printf "mobula_last_tx_hash{chain=\"bnb\",region=\"%s\",pool_address=\"%s\"}" $labels.region $labels.pool_address) }}https://bscscan.com/tx/{{ . | first | label "tx_hash" }}{{ end }} + + # Missing head lag metrics (no data for 5 minutes = monitor down) + - alert: MissingHeadLagMetrics + expr: absent(head_lag_seconds) + for: 5m + labels: + severity: critical + alert_type: missing_head_lag + app: aggregator_latency_monitor + annotations: + summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}Head lag metrics missing' + description: "No head_lag_seconds metrics received for 5 minutes. Check if monitors are running." + + # Per-aggregator staleness: fires when one provider stops pushing data + # (e.g. Codex WS disconnect) while others keep running — global absent() + # would NOT catch this. + - alert: AggregatorHeadLagStale + expr: (time() - timestamp(head_lag_seconds)) > 300 + for: 1m + labels: + severity: warning + alert_type: head_lag_stale + app: aggregator_latency_monitor + annotations: + summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}{{ $labels.aggregator }} head_lag stale on {{ $labels.chain }} ({{ $labels.region }})' + description: | + **{{ $labels.aggregator }}** hasn't pushed a head_lag sample for **{{ $labels.chain }} / {{ $labels.region }}** in over 5 minutes. + + Likely cause: WebSocket disconnected, JWT expired, proxy IP banned, or auth cookie rotated. + + Check the monitor logs for `[HEAD-LAG][{{ $labels.aggregator | toUpper }}]` errors. + + # Service availability + - alert: CodexServiceDown + expr: up{job="latency_monitor"} == 0 + for: 2m + labels: + severity: critical + alert_type: service_down + app: aggregator_latency_monitor + annotations: + summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}Latency monitor service is down' + description: "The aggregator latency monitor has been down for 2 minutes. No metrics are being collected." diff --git a/harnesses/aggregator-latency-benchmark/prometheus/alertmanager.yml b/harnesses/aggregator-latency-benchmark/prometheus/alertmanager.yml new file mode 100644 index 00000000..77b9e263 --- /dev/null +++ b/harnesses/aggregator-latency-benchmark/prometheus/alertmanager.yml @@ -0,0 +1,36 @@ +global: + resolve_timeout: 5m + +route: + group_by: ['alertname', 'chain', 'aggregator'] + group_wait: 10s + group_interval: 30s + repeat_interval: 4h + receiver: 'slack-webhook' + +receivers: + - name: 'slack-webhook' + webhook_configs: + - url: 'https://agent-slack-production.up.railway.app/webhook/grafana' + send_resolved: true + +inhibit_rules: + # Inhibit warning alerts if critical alert is firing + - source_match: + severity: 'critical' + target_match: + severity: 'warning' + equal: ['alertname', 'chain', 'aggregator'] + + # Inhibit stale metrics alerts if service is down + - source_match: + alert_type: 'service_down' + target_match: + alert_type: 'stale_metrics' + + # Inhibit stale metrics if missing metrics alert is firing + - source_match: + alert_type: 'missing_metrics' + target_match: + alert_type: 'stale_metrics' + equal: ['aggregator'] diff --git a/harnesses/aggregator-latency-benchmark/prometheus/prometheus.staging.yml b/harnesses/aggregator-latency-benchmark/prometheus/prometheus.staging.yml new file mode 100644 index 00000000..a5c329de --- /dev/null +++ b/harnesses/aggregator-latency-benchmark/prometheus/prometheus.staging.yml @@ -0,0 +1,26 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + # Staging external label triggers [STAGING] prefix in alert summaries via templating. + external_labels: + environment: 'staging' + +# Load alert rules (shared with production; [STAGING] prefix is conditional via $externalLabels) +rule_files: + - '/etc/prometheus/alert_rules.yml' + +# Staging Alertmanager (separate from production) +alerting: + alertmanagers: + - static_configs: + - targets: ['alertmanager-staging.railway.internal:9093'] + +# Staging scrape targets — 3 regions, same pattern as production +scrape_configs: + - job_name: 'monitor-staging' + static_configs: + - targets: + - 'agg-staging-eu.railway.internal:2112' + - 'agg-staging-us.railway.internal:2112' + - 'agg-staging-sgp.railway.internal:2112' + metrics_path: /metrics diff --git a/harnesses/aggregator-latency-benchmark/prometheus/prometheus.yml b/harnesses/aggregator-latency-benchmark/prometheus/prometheus.yml new file mode 100644 index 00000000..0b9c057d --- /dev/null +++ b/harnesses/aggregator-latency-benchmark/prometheus/prometheus.yml @@ -0,0 +1,38 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + # Expanded at startup via --enable-feature=expand-external-labels. + # Unset => empty value => alert rule templates treat it as "not staging" and don't prefix. + external_labels: + environment: '${ENVIRONMENT}' + +# Load alert rules +rule_files: + - '/etc/prometheus/alert_rules.yml' + +# Alertmanager configuration +alerting: + alertmanagers: + - static_configs: + - targets: ['alertmanager.railway.internal:9093'] + +scrape_configs: + - job_name: 'monitor' + static_configs: + - targets: + - 'agg-eu-west.railway.internal:2112' + - 'alertmanager.railway.internal:2112' + - 'aggregator-east-usa.railway.internal:2112' + - 'agg-sgp.railway.internal:2112' + metrics_path: /metrics + + # OpenChainBench bench №004 — runs the Pulse V2 feeder + metadata + # coverage worker. Internal-only Railway service; no public URL needed. + - job_name: 'metadata-coverage' + static_configs: + - targets: + - 'metadata-coverage.railway.internal:2112' + labels: + benchmark: metadata-coverage + metrics_path: /metrics + diff --git a/harnesses/chain-kpis/.env.example b/harnesses/chain-kpis/.env.example new file mode 100644 index 00000000..5d5c8c41 --- /dev/null +++ b/harnesses/chain-kpis/.env.example @@ -0,0 +1,5 @@ +# Mobula API key (optional: enables the Mobula source) +MOBULA_API_KEY= +# Refresh intervals in minutes (optional) +MOBULA_REFRESH_MINUTES= +DEFILLAMA_REFRESH_MINUTES= diff --git a/harnesses/chain-kpis/Dockerfile b/harnesses/chain-kpis/Dockerfile new file mode 100644 index 00000000..71bd71a6 --- /dev/null +++ b/harnesses/chain-kpis/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 || true + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux go build -o /app/chain-kpis ./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/chain-kpis /app/chain-kpis + +EXPOSE 2112 + +CMD ["/app/chain-kpis"] diff --git a/harnesses/chain-kpis/README.md b/harnesses/chain-kpis/README.md new file mode 100644 index 00000000..1634989d --- /dev/null +++ b/harnesses/chain-kpis/README.md @@ -0,0 +1,80 @@ +# chain-kpis + +Per-chain KPI exporter for the OCB `/chains/` page strip. + +## What it does + +Polls DefiLlama and Mobula on a fixed cadence, computes the OCB-canonical +per-chain KPI set, and exposes Prometheus gauges on `:2112/metrics` that +the OCB site reads from on every SSR render of `/chains/`. + +## Sources & gauges + +| Gauge | Source | Cadence | +|---|---|---| +| `chain_tvl_usd{chain}` | DefiLlama `/v2/historicalChainTvl/` | 15 min | +| `chain_dex_volume_24h_usd{chain}` | DefiLlama `/overview/dexs/` | 15 min | +| `chain_stables_mcap_usd{chain}` | DefiLlama `/stablecoincharts/` | 15 min | +| `chain_native_price_usd{chain, symbol}` | Mobula `/api/1/market/data?symbol=` | 5 min | +| `chain_native_mcap_usd{chain, symbol}` | Mobula `/api/1/market/data?symbol=` | 5 min | +| `chain_mobula_tokens_indexed{chain}` | Mobula `/api/1/market/blockchain/stats?blockchain=` | 5 min | + +Plus observability: +- `chain_kpis_last_refresh_timestamp_seconds{chain, source}` +- `chain_kpis_fetch_latency_milliseconds{chain, source}` +- `chain_kpis_fetch_errors_total{chain, source, error_type}` +- `chain_kpis_last_tick_unix` + +## Chain registry + +The set of chains (slug, DefiLlama name, Mobula name, native symbol) is +hardcoded in `cmd/script/registry.go`. It MUST mirror the OCB site's +`src/lib/chains.ts` registry. Adding a new chain: + +1. Append to `Registry` in `cmd/script/registry.go` +2. Append to `CHAINS` in `src/lib/chains.ts` on the OCB site +3. Redeploy both + +Names were verified live against DefiLlama `/v2/chains` and Mobula +`/api/1/blockchains`. Empty `DefiLlama`/`Mobula` fields = source confirmed +unsupported for that chain (e.g. Monero on DefiLlama; Stellar, Cardano, +Litecoin, Monero on Mobula). The page renders only the cards with data. + +## Env vars + +| Var | Default | Required | +|---|---|---| +| `MOBULA_API_KEY` | (empty) | Required for `mobula-native` and `mobula-stats` fetchers; without it the harness logs a warning and skips Mobula. DefiLlama still works. | +| `DEFILLAMA_REFRESH_MINUTES` | `15` | Optional override. | +| `MOBULA_REFRESH_MINUTES` | `5` | Optional override. | + +## Port + +Hardcoded `:2112` per the OCB harness convention. The shared Prom-gateway +on Railway is configured to scrape `:2112` from every OCB harness. Do not +listen on `$PORT` — Railway sets that env var for its proxy layer; the +harness ignores it. + +## Graceful degradation + +- DefiLlama 404 / empty body for a chain → that chain's gauge is left + untouched (Prom carry-forward); a `chain_kpis_fetch_errors_total` + counter is incremented with `error_type="not_tracked"` or + `error_type="not_found"` so dashboards can distinguish genuine outages + from expected gaps. +- Mobula 429 / 401 → all chains for that fetcher are skipped this tick; + DefiLlama keeps publishing. +- One chain failure does not affect any other chain (each fetch is its + own goroutine). + +## Local run + +```bash +MOBULA_API_KEY=… \ +DEFILLAMA_REFRESH_MINUTES=60 \ +MOBULA_REFRESH_MINUTES=60 \ +go run ./cmd/script + +# In another terminal: +curl -s http://localhost:2112/metrics | grep '^chain_' | head +``` diff --git a/harnesses/chain-kpis/cmd/script/config.go b/harnesses/chain-kpis/cmd/script/config.go new file mode 100644 index 00000000..dc2733ce --- /dev/null +++ b/harnesses/chain-kpis/cmd/script/config.go @@ -0,0 +1,53 @@ +package main + +import ( + "fmt" + "os" + "strconv" + "time" +) + +// Config holds runtime knobs. Set via env vars on Railway. +type Config struct { + // Mobula REST key. Required for /market/data and /market/blockchain/stats + // (both endpoints rate-limit free traffic to ~10 req/min, the key lifts + // that to a level that comfortably absorbs 21 chains × 2 endpoints + // every refresh tick.) Without a key, Mobula returns 429 immediately. + MobulaAPIKey string + + // How often DefiLlama is polled per chain. 15 min is the conservative + // default: their public API is generous but the TVL value barely moves + // inside a 15-min window (intra-day deltas are noise vs the day-over- + // day signal we surface on the chain page). 3 calls/h × 21 chains = 63 + // req/h, well under any sane rate-limit ceiling. + DefillamaRefreshInterval time.Duration + + // Mobula tick. Faster because we own the source — and native-token + // prices move on shorter cycles than DEX-TVL aggregates. 5 min keeps + // the price card fresh without hammering. 12 calls/h × 21 chains × 2 + // endpoints = 504 req/h, fine on a paid key. + MobulaRefreshInterval time.Duration +} + +func loadConfig() *Config { + c := &Config{ + MobulaAPIKey: os.Getenv("MOBULA_API_KEY"), + DefillamaRefreshInterval: 15 * time.Minute, + MobulaRefreshInterval: 5 * time.Minute, + } + + if v := os.Getenv("DEFILLAMA_REFRESH_MINUTES"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + c.DefillamaRefreshInterval = time.Duration(n) * time.Minute + } + } + if v := os.Getenv("MOBULA_REFRESH_MINUTES"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + c.MobulaRefreshInterval = time.Duration(n) * time.Minute + } + } + + fmt.Printf("Config: chains=%d, defillama_every=%v, mobula_every=%v, mobula_key=%v\n", + len(Registry), c.DefillamaRefreshInterval, c.MobulaRefreshInterval, c.MobulaAPIKey != "") + return c +} diff --git a/harnesses/chain-kpis/cmd/script/defillama.go b/harnesses/chain-kpis/cmd/script/defillama.go new file mode 100644 index 00000000..1bda3e7d --- /dev/null +++ b/harnesses/chain-kpis/cmd/script/defillama.go @@ -0,0 +1,200 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// DefiLlama lives behind a public unauthenticated API. We hit 3 endpoints +// per chain that has a DefiLlama mapping: +// 1. /v2/historicalChainTvl/ — last sample = current TVL +// 2. /overview/dexs/ — total24h = aggregate DEX volume +// 3. /stablecoincharts/?stablecoin=1 +// — last point's USD-pegged total +// +// Each endpoint is its own goroutine inside fetchDefillamaChain so a +// stuck TVL request doesn't starve the stables fetch for the same chain. +// Failures are bucketed into chain_kpis_fetch_errors_total{source="defillama"} +// and the gauge is left untouched (carry-forward via Prom retention). + +const ( + defillamaBase = "https://api.llama.fi" + stablesLlamaBase = "https://stablecoins.llama.fi" +) + +var httpClientDefillama = &http.Client{Timeout: 15 * time.Second} + +func fetchAllDefillama() { + for _, c := range Registry { + c := c + if c.DefiLlama == "" { + continue + } + go fetchDefillamaChain(c) + } +} + +func fetchDefillamaChain(c Chain) { + start := time.Now() + defer func() { + chainKpisFetchLatencyMs.WithLabelValues(c.Slug, "defillama").Set(float64(time.Since(start).Milliseconds())) + }() + + anyOK := false + + if tvl, err := defillamaTvl(c.DefiLlama); err == nil { + chainTvlUsd.WithLabelValues(c.Slug).Set(tvl) + anyOK = true + } else { + chainKpisFetchErrors.WithLabelValues(c.Slug, "defillama", classifyError(err.Error())).Inc() + fmt.Printf("[defillama][%s] tvl error: %v\n", c.Slug, err) + } + + if vol, err := defillamaDexVolume24h(c.DefiLlama); err == nil { + chainDexVolume24hUsd.WithLabelValues(c.Slug).Set(vol) + anyOK = true + } else { + chainKpisFetchErrors.WithLabelValues(c.Slug, "defillama", classifyError(err.Error())).Inc() + fmt.Printf("[defillama][%s] dex24h error: %v\n", c.Slug, err) + } + + if mcap, err := defillamaStablesMcap(c.DefiLlama); err == nil { + chainStablesMcapUsd.WithLabelValues(c.Slug).Set(mcap) + anyOK = true + } else { + chainKpisFetchErrors.WithLabelValues(c.Slug, "defillama", classifyError(err.Error())).Inc() + fmt.Printf("[defillama][%s] stables error: %v\n", c.Slug, err) + } + + if anyOK { + chainKpisLastRefresh.WithLabelValues(c.Slug, "defillama").Set(float64(time.Now().Unix())) + } + chainKpisLastTickUnix.Set(float64(time.Now().Unix())) +} + +// defillamaTvl reads the historical TVL series and returns the LAST sample. +// DefiLlama publishes one daily aggregate; the last point is "current". +func defillamaTvl(chainName string) (float64, error) { + url := fmt.Sprintf("%s/v2/historicalChainTvl/%s", defillamaBase, encodePath(chainName)) + body, err := getJSON(httpClientDefillama, url) + if err != nil { + return 0, err + } + var arr []struct { + Date int64 `json:"date"` + Tvl float64 `json:"tvl"` + } + if err := json.Unmarshal(body, &arr); err != nil { + return 0, fmt.Errorf("parse_error: %w", err) + } + if len(arr) == 0 { + return 0, fmt.Errorf("empty_series") + } + return arr[len(arr)-1].Tvl, nil +} + +// defillamaDexVolume24h reads /overview/dexs/ and returns total24h. +// This is the aggregate of every DefiLlama-tracked DEX on that chain. +func defillamaDexVolume24h(chainName string) (float64, error) { + url := fmt.Sprintf("%s/overview/dexs/%s?excludeTotalDataChart=true&excludeTotalDataChartBreakdown=true", defillamaBase, encodePath(chainName)) + body, err := getJSON(httpClientDefillama, url) + if err != nil { + return 0, err + } + var resp struct { + Total24h float64 `json:"total24h"` + } + if err := json.Unmarshal(body, &resp); err != nil { + return 0, fmt.Errorf("parse_error: %w", err) + } + return resp.Total24h, nil +} + +// defillamaStablesMcap reads /stablecoincharts/?stablecoin=1 and +// returns the last sample's USD-pegged total. The `stablecoin=1` filter +// is required by the API though we don't filter by a specific stablecoin; +// the response is the chain-wide aggregate when no specific id is given. +func defillamaStablesMcap(chainName string) (float64, error) { + url := fmt.Sprintf("%s/stablecoincharts/%s?stablecoin=1", stablesLlamaBase, encodePath(chainName)) + body, err := getJSON(httpClientDefillama, url) + if err != nil { + return 0, err + } + // DefiLlama returns 200 + empty body for chains it tracks but where no + // stablecoin issuance has been recorded (Stellar, Blast, Base at time + // of writing). Surface as a typed sentinel so the harness logs it as + // "not_tracked" rather than spamming parse_error. + if len(body) == 0 { + return 0, fmt.Errorf("not_tracked") + } + // DefiLlama returns `date` as a stringified unix timestamp here (the + // /v2/historicalChainTvl endpoint returns int64 — different convention + // per family of endpoints). We only need the value, so decode `date` + // as json.RawMessage to ignore typing. + var arr []struct { + Date json.RawMessage `json:"date"` + TotalCirculatingUSD struct { + PeggedUSD float64 `json:"peggedUSD"` + } `json:"totalCirculatingUSD"` + } + if err := json.Unmarshal(body, &arr); err != nil { + return 0, fmt.Errorf("parse_error: %w", err) + } + if len(arr) == 0 { + return 0, fmt.Errorf("empty_series") + } + return arr[len(arr)-1].TotalCirculatingUSD.PeggedUSD, nil +} + +// encodePath is a minimal URL path-segment encoder. DefiLlama chain names +// can contain spaces ("BNB Smart Chain", "ZKsync Era") that have to be +// percent-encoded; url.PathEscape handles this without escaping legitimate +// path separators we never include. +func encodePath(s string) string { + out := make([]byte, 0, len(s)) + for _, r := range []byte(s) { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.' || r == '~' { + out = append(out, r) + } else { + out = append(out, '%', hex(r>>4), hex(r&0x0F)) + } + } + return string(out) +} + +func hex(b byte) byte { + if b < 10 { + return '0' + b + } + return 'A' + (b - 10) +} + +// getJSON is a tiny wrapper around the HTTP GET that returns the body bytes +// or a classified error string. The classifier on the metrics side reads +// substrings like "timeout", "429", "5xx" — we surface those in the +// error message so the bucket lands correctly. +func getJSON(client *http.Client, url string) ([]byte, error) { + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("User-Agent", "OCB-chain-kpis/1.0") + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("request_error: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return nil, fmt.Errorf("status_%d: %s", resp.StatusCode, truncate(string(body), 200)) + } + return body, nil +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] +} diff --git a/harnesses/chain-kpis/cmd/script/main.go b/harnesses/chain-kpis/cmd/script/main.go new file mode 100644 index 00000000..c212baa5 --- /dev/null +++ b/harnesses/chain-kpis/cmd/script/main.go @@ -0,0 +1,99 @@ +// chain-kpis is a small Prom-exporter harness that polls DefiLlama and +// Mobula for per-chain KPIs the OCB site renders on /chains/. +// +// ─── Gauges exposed ─────────────────────────────────────────────── +// chain_tvl_usd{chain} — DefiLlama TVL +// chain_dex_volume_24h_usd{chain} — DefiLlama 24h DEX vol +// chain_stables_mcap_usd{chain} — DefiLlama stables mcap +// chain_native_price_usd{chain, symbol} — Mobula native price +// chain_native_mcap_usd{chain, symbol} — Mobula native mcap +// chain_mobula_tokens_indexed{chain} — Mobula tokens count +// +// Each gauge is publish-then-leave: if a fetch fails for one chain on +// one source, the previous value carries forward via Prom retention, +// other chains are unaffected, and the error is bucketed in +// chain_kpis_fetch_errors_total{chain, source, error_type}. +// +// HTTP server is fixed at :2112 per the OCB harness convention (see +// CLAUDE.md memory note: every OCB harness on Railway hard-codes :2112 +// so the shared Prom-gateway scrape target matches). +package main + +import ( + "fmt" + "os" + "os/signal" + "sync" + "syscall" + "time" +) + +func main() { + fmt.Println("=== chain-kpis harness ===") + fmt.Println("Per-chain TVL + DEX vol + stables (DefiLlama) and native price + mcap + tokens (Mobula).") + fmt.Println("Exposes /metrics on :2112.") + + cfg := loadConfig() + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + + var wg sync.WaitGroup + stop := make(chan struct{}) + + 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) + } + }() + + wg.Add(1) + go func() { + defer wg.Done() + runDefillamaLoop(cfg, stop) + }() + + wg.Add(1) + go func() { + defer wg.Done() + runMobulaLoop(cfg, stop) + }() + + <-sigChan + fmt.Println("\nShutting down...") + close(stop) + wg.Wait() +} + +func runDefillamaLoop(cfg *Config, stop <-chan struct{}) { + tick := time.NewTicker(cfg.DefillamaRefreshInterval) + defer tick.Stop() + + fetchAllDefillama() + for { + select { + case <-stop: + return + case <-tick.C: + fetchAllDefillama() + } + } +} + +func runMobulaLoop(cfg *Config, stop <-chan struct{}) { + tick := time.NewTicker(cfg.MobulaRefreshInterval) + defer tick.Stop() + + fetchAllMobula(cfg) + for { + select { + case <-stop: + return + case <-tick.C: + fetchAllMobula(cfg) + } + } +} diff --git a/harnesses/chain-kpis/cmd/script/metrics.go b/harnesses/chain-kpis/cmd/script/metrics.go new file mode 100644 index 00000000..0c5cabcd --- /dev/null +++ b/harnesses/chain-kpis/cmd/script/metrics.go @@ -0,0 +1,140 @@ +package main + +import ( + "net/http" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +// All gauges are keyed by `chain=`. The site reads them with +// the exact same selector via `fetchChainKpis(slug)`. Naming convention +// is `chain__` so a reader can tell at a glance which +// API the value came from. +var ( + // DefiLlama-sourced ───────────────────────────────────────────── + chainTvlUsd = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "chain_tvl_usd", + Help: "Total Value Locked in USD across all DeFi protocols on this chain. Source: DefiLlama /v2/historicalChainTvl. Updated every 15 min.", + }, + []string{"chain"}, + ) + chainDexVolume24hUsd = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "chain_dex_volume_24h_usd", + Help: "Aggregate 24h DEX trading volume in USD on this chain across DefiLlama-tracked DEXes. Source: DefiLlama /overview/dexs. Updated every 15 min.", + }, + []string{"chain"}, + ) + chainStablesMcapUsd = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "chain_stables_mcap_usd", + Help: "USD-pegged stablecoin circulating market cap on this chain. Source: DefiLlama /stablecoincharts. Updated every 15 min.", + }, + []string{"chain"}, + ) + + // Mobula-sourced ──────────────────────────────────────────────── + chainNativePriceUsd = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "chain_native_price_usd", + Help: "Current USD price of the chain's native token. Source: Mobula /market/data. Updated every 5 min.", + }, + []string{"chain", "symbol"}, + ) + chainNativeMcapUsd = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "chain_native_mcap_usd", + Help: "Circulating market cap (USD) of the chain's native token. Source: Mobula /market/data. Updated every 5 min.", + }, + []string{"chain", "symbol"}, + ) + chainMobulaTokensIndexed = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "chain_mobula_tokens_indexed", + Help: "Number of tokens Mobula's indexer tracks on this chain. Source: Mobula /market/blockchain/stats. Updated every 5 min.", + }, + []string{"chain"}, + ) + + // Observability ────────────────────────────────────────────────── + chainKpisLastRefresh = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "chain_kpis_last_refresh_timestamp_seconds", + Help: "Unix timestamp of the last successful refresh per chain per source.", + }, + []string{"chain", "source"}, + ) + chainKpisFetchLatencyMs = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "chain_kpis_fetch_latency_milliseconds", + Help: "Wall-clock fetch latency per chain per source.", + }, + []string{"chain", "source"}, + ) + chainKpisFetchErrors = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "chain_kpis_fetch_errors_total", + Help: "Total number of fetch failures per chain per source, by error type.", + }, + []string{"chain", "source", "error_type"}, + ) + chainKpisLastTickUnix = prometheus.NewGauge( + prometheus.GaugeOpts{ + Name: "chain_kpis_last_tick_unix", + Help: "Unix timestamp of the last harness tick (any source). Liveness probe for the cron alerter.", + }, + ) +) + +func init() { + prometheus.MustRegister( + chainTvlUsd, chainDexVolume24hUsd, chainStablesMcapUsd, + chainNativePriceUsd, chainNativeMcapUsd, chainMobulaTokensIndexed, + chainKpisLastRefresh, chainKpisFetchLatencyMs, chainKpisFetchErrors, + chainKpisLastTickUnix, + ) +} + +// classifyError buckets a fetch error string into a small finite enum so +// chain_kpis_fetch_errors_total stays bounded in cardinality. Same shape +// as network-coverage's classifier (timeout, auth, rate_limit, server, +// other) so the OCB dashboards can reuse one template across harnesses. +func classifyError(msg string) string { + switch { + case contains(msg, "timeout"), contains(msg, "deadline"): + return "timeout" + case contains(msg, "401"), contains(msg, "403"), contains(msg, "unauthorized"): + return "auth" + case contains(msg, "429"): + return "rate_limit" + case contains(msg, "500"), contains(msg, "502"), contains(msg, "503"), contains(msg, "504"): + return "server_error" + case contains(msg, "404"): + return "not_found" + case contains(msg, "not_tracked"), contains(msg, "empty_series"): + // Expected: the upstream confirmed the chain is supported but has + // no data for this KPI yet (e.g. Stellar/Blast for stables today). + // Keep it out of "other" so dashboards don't false-positive. + return "not_tracked" + default: + return "other" + } +} + +func contains(s, sub string) bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} + +func StartMetricsServer(addr string) error { + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.Handler()) + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("OK")) }) + return http.ListenAndServe(addr, mux) +} diff --git a/harnesses/chain-kpis/cmd/script/mobula.go b/harnesses/chain-kpis/cmd/script/mobula.go new file mode 100644 index 00000000..816689ff --- /dev/null +++ b/harnesses/chain-kpis/cmd/script/mobula.go @@ -0,0 +1,167 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "time" +) + +// Mobula has two endpoints we consume per chain: +// 1. /api/1/market/data?symbol= — native token price + mcap +// 2. /api/1/market/blockchain/stats?blockchain= +// — DEX liquidity + 24h volume + tokens-indexed-by-Mobula count +// +// The native-token endpoint is per-symbol so several OCB chains map to the +// same call (every L2 = ETH); we dedup before firing requests so the API +// only sees one /market/data per native symbol per tick. +// +// Authorization header takes a raw API key (no "Bearer" prefix, see +// mobula docs). Missing key = 429 immediately; the harness logs and +// keeps polling DefiLlama so the page still renders the DeFi cards. + +const ( + mobulaBase = "https://api.mobula.io" + mobulaUA = "OCB-chain-kpis/1.0" +) + +var httpClientMobula = &http.Client{Timeout: 15 * time.Second} + +func fetchAllMobula(cfg *Config) { + if cfg.MobulaAPIKey == "" { + fmt.Println("[mobula] MOBULA_API_KEY missing, skipping Mobula fetch") + return + } + + // Dedup native symbols. The 21 chains today flatten to 11 unique + // natives (every L2 = ETH). + natives := map[string][]Chain{} + for _, c := range Registry { + if c.NativeSymbol == "" { + continue + } + natives[c.NativeSymbol] = append(natives[c.NativeSymbol], c) + } + for sym, chains := range natives { + sym, chains := sym, chains + go fetchMobulaNative(cfg, sym, chains) + } + + // Chain-stats endpoint is per chain. Fire each as its own goroutine. + for _, c := range Registry { + c := c + if c.Mobula == "" { + continue + } + go fetchMobulaChainStats(cfg, c) + } +} + +// fetchMobulaNative pulls native price + mcap for one symbol and writes +// the value to every chain that uses this native (ETH → all rollups). +func fetchMobulaNative(cfg *Config, symbol string, chains []Chain) { + start := time.Now() + url := fmt.Sprintf("%s/api/1/market/data?symbol=%s", mobulaBase, encodePath(symbol)) + body, err := getJSONWithAuth(httpClientMobula, url, cfg.MobulaAPIKey) + latency := float64(time.Since(start).Milliseconds()) + for _, c := range chains { + chainKpisFetchLatencyMs.WithLabelValues(c.Slug, "mobula-native").Set(latency) + } + if err != nil { + bucket := classifyError(err.Error()) + for _, c := range chains { + chainKpisFetchErrors.WithLabelValues(c.Slug, "mobula-native", bucket).Inc() + } + fmt.Printf("[mobula-native][%s] error: %v\n", symbol, err) + return + } + var resp struct { + Data struct { + Price float64 `json:"price"` + MarketCap float64 `json:"market_cap"` + } `json:"data"` + } + if err := json.Unmarshal(body, &resp); err != nil { + for _, c := range chains { + chainKpisFetchErrors.WithLabelValues(c.Slug, "mobula-native", "parse").Inc() + } + fmt.Printf("[mobula-native][%s] parse error: %v\n", symbol, err) + return + } + for _, c := range chains { + if resp.Data.Price > 0 { + chainNativePriceUsd.WithLabelValues(c.Slug, symbol).Set(resp.Data.Price) + } + if resp.Data.MarketCap > 0 { + chainNativeMcapUsd.WithLabelValues(c.Slug, symbol).Set(resp.Data.MarketCap) + } + chainKpisLastRefresh.WithLabelValues(c.Slug, "mobula-native").Set(float64(time.Now().Unix())) + } + chainKpisLastTickUnix.Set(float64(time.Now().Unix())) +} + +// fetchMobulaChainStats pulls the blockchain-stats payload for one chain +// and writes the tokens-indexed gauge. We deliberately drop the volume +// and liquidity series here — the canonical DEX volume / TVL signal on +// the chain page comes from DefiLlama for consistency across chains +// (DefiLlama covers chains Mobula doesn't, and the public reads them as +// the standard reference). The Mobula "tokens indexed" gauge is the +// unique data we publish from this endpoint. +func fetchMobulaChainStats(cfg *Config, c Chain) { + start := time.Now() + url := fmt.Sprintf("%s/api/1/market/blockchain/stats?blockchain=%s", mobulaBase, encodePath(c.Mobula)) + body, err := getJSONWithAuth(httpClientMobula, url, cfg.MobulaAPIKey) + chainKpisFetchLatencyMs.WithLabelValues(c.Slug, "mobula-stats").Set(float64(time.Since(start).Milliseconds())) + if err != nil { + chainKpisFetchErrors.WithLabelValues(c.Slug, "mobula-stats", classifyError(err.Error())).Inc() + fmt.Printf("[mobula-stats][%s] error: %v\n", c.Slug, err) + return + } + var resp struct { + Data struct { + TokensHistory [][]float64 `json:"tokens_history"` + } `json:"data"` + } + if err := json.Unmarshal(body, &resp); err != nil { + chainKpisFetchErrors.WithLabelValues(c.Slug, "mobula-stats", "parse").Inc() + fmt.Printf("[mobula-stats][%s] parse error: %v\n", c.Slug, err) + return + } + if n := len(resp.Data.TokensHistory); n > 0 { + last := resp.Data.TokensHistory[n-1] + if len(last) >= 2 { + chainMobulaTokensIndexed.WithLabelValues(c.Slug).Set(last[1]) + } + } + chainKpisLastRefresh.WithLabelValues(c.Slug, "mobula-stats").Set(float64(time.Now().Unix())) +} + +// getJSONWithAuth is the auth-bearing variant of getJSON. +// Mobula expects the bare API key in the Authorization header (no Bearer +// prefix), per the docs and verified against the live API. +func getJSONWithAuth(client *http.Client, url, key string) ([]byte, error) { + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("Authorization", key) + req.Header.Set("User-Agent", mobulaUA) + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("request_error: %w", err) + } + defer resp.Body.Close() + body := make([]byte, 0, 4096) + buf := make([]byte, 4096) + for { + n, err := resp.Body.Read(buf) + if n > 0 { + body = append(body, buf[:n]...) + } + if err != nil { + break + } + } + if resp.StatusCode != 200 { + return nil, fmt.Errorf("status_%d: %s", resp.StatusCode, truncate(string(body), 200)) + } + return body, nil +} diff --git a/harnesses/chain-kpis/cmd/script/registry.go b/harnesses/chain-kpis/cmd/script/registry.go new file mode 100644 index 00000000..b3ca6768 --- /dev/null +++ b/harnesses/chain-kpis/cmd/script/registry.go @@ -0,0 +1,55 @@ +package main + +// Chain is one OCB-tracked chain with its source-of-truth mappings. +// The slug field MUST match the OCB site's `src/lib/chains.ts` registry so +// the Prom selector `{chain=""}` matches what the bench page reads. +// +// DefiLlama name: the canonical chain name DefiLlama uses on +// /v2/historicalChainTvl/, /overview/dexs/, and +// /stablecoincharts/. Verified live by reading +// https://api.llama.fi/v2/chains and matching exact casing. +// Empty = DefiLlama doesn't cover this chain (Monero today). +// +// Mobula name: the value the harness passes as ?blockchain= to +// /api/1/market/blockchain/stats. Verified live by reading +// /api/1/blockchains and matching. Empty = unsupported. +// +// Native symbol: the canonical native-token symbol Mobula serves via +// /api/1/market/data?symbol=. Verified live, 100% coverage. +type Chain struct { + Slug string + DefiLlama string + Mobula string + NativeSymbol string +} + +// Registry is the canonical list of OCB-tracked chains. +// Order = display order in the /chains hub. +// Mirror of src/lib/chains.ts CHAINS array on the OCB site, kept in sync +// manually. Adding a new chain: append here, append on the site, redeploy +// both. New rows take effect on the next harness tick. +var Registry = []Chain{ + // L1 + {Slug: "ethereum", DefiLlama: "Ethereum", Mobula: "Ethereum", NativeSymbol: "ETH"}, + {Slug: "solana", DefiLlama: "Solana", Mobula: "Solana", NativeSymbol: "SOL"}, + {Slug: "bnb", DefiLlama: "BSC", Mobula: "BNB Smart Chain (BEP20)", NativeSymbol: "BNB"}, + {Slug: "avalanche", DefiLlama: "Avalanche", Mobula: "Avalanche C-Chain", NativeSymbol: "AVAX"}, + {Slug: "sui", DefiLlama: "Sui", Mobula: "Sui", NativeSymbol: "SUI"}, + {Slug: "ton", DefiLlama: "TON", Mobula: "TON", NativeSymbol: "TON"}, + {Slug: "stellar", DefiLlama: "Stellar", Mobula: "", NativeSymbol: "XLM"}, + {Slug: "tron", DefiLlama: "Tron", Mobula: "TRON", NativeSymbol: "TRX"}, + {Slug: "cardano", DefiLlama: "Cardano", Mobula: "", NativeSymbol: "ADA"}, + {Slug: "litecoin", DefiLlama: "Litecoin", Mobula: "", NativeSymbol: "LTC"}, + {Slug: "monero", DefiLlama: "", Mobula: "", NativeSymbol: "XMR"}, + {Slug: "polygon", DefiLlama: "Polygon", Mobula: "Polygon", NativeSymbol: "POL"}, + // L2 + {Slug: "arbitrum", DefiLlama: "Arbitrum", Mobula: "Arbitrum", NativeSymbol: "ETH"}, + {Slug: "optimism", DefiLlama: "Optimism", Mobula: "Optimistic", NativeSymbol: "ETH"}, + {Slug: "base", DefiLlama: "Base", Mobula: "Base", NativeSymbol: "ETH"}, + {Slug: "zksync", DefiLlama: "ZKsync Era", Mobula: "ZkSync", NativeSymbol: "ETH"}, + {Slug: "linea", DefiLlama: "Linea", Mobula: "Linea", NativeSymbol: "ETH"}, + {Slug: "scroll", DefiLlama: "Scroll", Mobula: "Scroll", NativeSymbol: "ETH"}, + {Slug: "blast", DefiLlama: "Blast", Mobula: "Blast", NativeSymbol: "ETH"}, + {Slug: "mantle", DefiLlama: "Mantle", Mobula: "Mantle", NativeSymbol: "MNT"}, + {Slug: "taiko", DefiLlama: "Taiko", Mobula: "Taiko", NativeSymbol: "ETH"}, +} diff --git a/harnesses/chain-kpis/go.mod b/harnesses/chain-kpis/go.mod new file mode 100644 index 00000000..ca48de10 --- /dev/null +++ b/harnesses/chain-kpis/go.mod @@ -0,0 +1,17 @@ +module github.com/mobula/chain-kpis + +go 1.24 + +require github.com/prometheus/client_golang v1.20.5 + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/klauspost/compress v1.17.9 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // 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 + golang.org/x/sys v0.22.0 // indirect + google.golang.org/protobuf v1.34.2 // indirect +) diff --git a/harnesses/chain-kpis/go.sum b/harnesses/chain-kpis/go.sum new file mode 100644 index 00000000..d5318cf8 --- /dev/null +++ b/harnesses/chain-kpis/go.sum @@ -0,0 +1,24 @@ +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/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +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/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/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= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= +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= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= diff --git a/harnesses/hl-archive/.env.example b/harnesses/hl-archive/.env.example new file mode 100644 index 00000000..e36e17f7 --- /dev/null +++ b/harnesses/hl-archive/.env.example @@ -0,0 +1,10 @@ +# Shared secret for /v1/aggregates (required in serve mode) +HL_ARCHIVE_API_KEY= +# Upstash KV push (optional: push skipped when unset) +UPSTASH_REDIS_REST_URL= +UPSTASH_REDIS_REST_TOKEN= +# Snapshot key (default ocb:hl-archive:v1) +HL_ARCHIVE_UPSTASH_KEY= +# Daily cron hour UTC (default 2) +HL_ARCHIVE_CRON_HOUR= +LOG_LEVEL= diff --git a/harnesses/hl-archive/DEPLOY.md b/harnesses/hl-archive/DEPLOY.md new file mode 100644 index 00000000..28b0cfae --- /dev/null +++ b/harnesses/hl-archive/DEPLOY.md @@ -0,0 +1,88 @@ +# hl-archive — Railway deploy + +Single-region single-instance Go service. Ingests Hyperliquid CDN dumps +into a local DuckDB file, exposes a read-only HTTP API + Prometheus +metrics on `:2114`, and republishes a snapshot to Upstash for the OCB +site SSR. + +## Prereqs + +- Railway CLI: `npm i -g @railway/cli` then `railway login` +- Linked to the OCB Railway project: `railway link` from the monorepo root +- One Upstash Redis (REST) database, same project as the OCB site +- A 32-byte random API key for the service (see below) + +## First-time deploy + +Build context is the monorepo root, not the miniapp dir. Run from +`mobula-api/` (not from `miniapps/hl-archive/`), otherwise Railway will +upload only the miniapp tree and the Dockerfile path will not resolve. + +```bash +cd /path/to/mobula-api + +# 1. Create the service in the OCB project (one-time). +railway service create hl-archive + +# 2. Point your local checkout at it. +railway service hl-archive + +# 3. Set the secrets. Mark HL_ARCHIVE_API_KEY and +# UPSTASH_REDIS_REST_TOKEN as "Sensitive" in the Railway dashboard +# afterwards (the CLI cannot flip that bit). +railway variables set \ + HL_ARCHIVE_API_KEY="$(openssl rand -base64 32)" \ + UPSTASH_REDIS_REST_URL="https://.upstash.io" \ + UPSTASH_REDIS_REST_TOKEN="" + +# 4. Add the persistent volume in the dashboard: +# Service -> Settings -> Volumes -> Add -> name=hl-archive-duckdb, +# mountPath=/data. railway.toml also declares it; whichever side +# creates it first wins. + +# 5. Deploy. +railway up +``` + +Subsequent deploys: a git push to the branch tracked by the Railway +service triggers a rebuild automatically, or `railway up` from the +monorepo root for an out-of-band deploy. + +## First-boot backfill + +On cold boot with an empty `/data/history.duckdb`, the service walks +upstream CDN dumps from `HL_ARCHIVE_BACKFILL_FROM` (default +`2025-08-01`) forward to "yesterday UTC". Expect ~10-30 min of CPU on +the first run depending on bucket size; subsequent daily runs at +`HL_ARCHIVE_CRON_HOUR=2` (UTC) only fetch the previous day. + +To kick a manual backfill of a specific range without waiting for the +cron, exec into the running container and call the binary's CLI mode: + +```bash +railway run hl-archive backfill --from=2025-08-01 --to=2025-08-31 +``` + +(The same binary serves both `serve` and `backfill` subcommands; the +container default is `serve`.) + +## Logs & metrics + +- **Logs**: Railway dashboard -> `hl-archive` -> Logs. JSON-structured + via slog, shippable to BetterStack/Loki without reformatting. +- **Metrics**: scraped by the shared OCB Prometheus at + `hl-archive.railway.internal:2114/metrics` (job `hl-archive` in + `miniapps/openchainbench-monitoring/prometheus/prometheus.yml`). + Key gauges: `hl_archive_lag_hours`, `hl_archive_db_size_bytes`, + `hl_archive_files_processed_total`, `hl_archive_cron_runs_total`. +- **Healthcheck**: `GET /health` on port 2114, no auth required. + +## Common errors + +| Symptom | Cause | Fix | +|---|---|---| +| `IO Error: Could not set lock on file` on boot | Two service replicas attached to the same DuckDB volume | Scale to 1 replica. DuckDB is single-writer; multi-replica is not supported. | +| `401 unauthorized` on Upstash REST calls in logs | `UPSTASH_REDIS_REST_TOKEN` missing or rotated | Re-set the env var, redeploy. Confirm the URL matches the same Upstash DB the token was issued for. | +| `403` / `429` from `stats-data.hyperliquid.xyz` during backfill | CDN throttling on bulk fetch | Reduce parallel fetches inside the harness (already capped, but a noisy neighbour on the egress IP can still trip it). Wait 10-15 min, the cron will retry on the next tick. | +| Healthcheck "failing" in Railway despite live `/metrics` | `PORT` env not set to `2114` | Confirm `PORT=2114` (the harness binds 2114 hardcoded, Railway probes `$PORT`). | +| Container restarts on every push | DuckDB file corrupted by an OOM mid-write | Stop the service, attach a one-shot container to the volume, run `duckdb /data/history.duckdb "PRAGMA database_size;"` to confirm, then either drop the file (full backfill on next boot) or restore from the last Upstash snapshot. | diff --git a/harnesses/hl-archive/Dockerfile b/harnesses/hl-archive/Dockerfile new file mode 100644 index 00000000..580c8fc2 --- /dev/null +++ b/harnesses/hl-archive/Dockerfile @@ -0,0 +1,92 @@ +# hl-archive — Go service that ingests Hyperliquid CDN dumps into a local +# DuckDB file, exposes a small read-only HTTP API + Prometheus metrics, and +# republishes a tiny snapshot to Upstash Redis for the OCB site. +# +# Layout notes: +# - go-duckdb v2 is a CGO module that bundles libduckdb statically (no apk +# package needed) but still requires a C toolchain at build time and a +# libc + libstdc++ at runtime, so we build with CGO_ENABLED=1 against +# glibc and run on debian:bookworm-slim. distroless/static and alpine +# are not viable here. +# - The container ships `data/builders.json` baked in at /app/data so the +# service has a usable default list even before any volume is mounted. +# - Port 2114 is hardcoded by the harness for the shared OCB Prom scrape +# contract (one bench = one fixed port). Railway $PORT is set to 2114 +# in railway.toml so the platform healthcheck routes to the same port. + +# ------------------------------------------------------------------ build -- +FROM golang:1.24-bookworm AS build + +WORKDIR /src + +# Toolchain needed by go-duckdb's cgo build (gcc, g++, make, headers). +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + ca-certificates \ + git \ + && rm -rf /var/lib/apt/lists/* + +# Cache module downloads as a separate layer. +COPY go.mod go.sum* ./ +RUN go mod download + +COPY . . + +# Build the binary. CGO is required (DuckDB driver). +RUN CGO_ENABLED=1 GOOS=linux GOARCH=amd64 \ + go build -trimpath -ldflags="-s -w" \ + -o /out/hl-archive ./cmd/hl-archive + +# -------------------------------------------------------------- runtime -- +FROM debian:bookworm-slim + +WORKDIR /app + +# Runtime deps: +# - ca-certificates : TLS to api.hyperliquid.xyz, stats-data.hyperliquid.xyz, +# Upstash REST. +# - liblz4-1 : Hyperliquid CDN dumps are LZ4-framed; the go-duckdb +# binding embeds duckdb statically but the bench code +# uses pierrec/lz4 in pure Go, so this is only kept +# as a safety net if a future codepath links the +# system lz4 (small, ~50 KB, no harm leaving it). +# - libstdc++6 : required by libduckdb embedded in the binary. +# - wget : container HEALTHCHECK probe. +# - tzdata : cron schedules / day-aligned aggregates need correct UTC. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + liblz4-1 \ + libstdc++6 \ + wget \ + tzdata \ + && rm -rf /var/lib/apt/lists/* + +# Bake the default builders list into the image at the path the service +# reads via HL_ARCHIVE_BUILDERS_FILE. +COPY data/builders.json /app/data/builders.json + +# Drop the binary on PATH so the ENTRYPOINT is unambiguous. +COPY --from=build /out/hl-archive /usr/local/bin/hl-archive + +# DuckDB file lives on the persistent Railway volume mounted at /data; +# pre-create the mountpoint so the binary can open the file on cold boot +# before any volume is attached (local docker runs, smoke tests). +RUN mkdir -p /data + +# Running as root: Railway mounts persistent volumes with root ownership +# and a non-root user (uid 10001) cannot write to /data, which makes +# DuckDB open() fail silently and the container restart in a loop with +# only "Mounting volume" logs. Switching back to non-root requires a +# chown entrypoint script that runs before USER drop, done in a follow-up. + +EXPOSE 2114 + +# /health and /metrics are exempted from X-API-Key auth by the harness so +# this probe stays unauthenticated. 5s start period covers DuckDB open. +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD wget -q --spider http://127.0.0.1:2114/health || exit 1 + +ENTRYPOINT ["/usr/local/bin/hl-archive"] +CMD ["serve"] diff --git a/harnesses/hl-archive/README.md b/harnesses/hl-archive/README.md new file mode 100644 index 00000000..c433cc5d --- /dev/null +++ b/harnesses/hl-archive/README.md @@ -0,0 +1,245 @@ +# hl-archive + +Historical Hyperliquid builder-fee archive: backfill once, ship a clean Upstash snapshot every day at 02:00 UTC. + +## What it does + +`hl-archive` walks the public Hyperliquid CDN (`stats-data.hyperliquid.xyz/Mainnet/builder_fills/...`), decompresses one `.csv.lz4` per (builder, day), and folds each fill into a per-day per-builder per-asset aggregate stored in a local DuckDB file. It exposes the aggregates on an HTTP API (auth-gated by `X-API-Key`) and pushes a compact JSON snapshot to Upstash KV so the OCB Next.js app can render windowed leaderboards (24 h / 7 d / 30 d / 90 d / 180 d / 1 y / all) without ever talking to the CDN itself. An in-process cron triggers daily at 02:00 UTC (configurable) to parse J-1 and refresh the snapshot. + +## Architecture + +```text + daily 02:00 UTC + | + v +[HL public CDN] -> [hl-archive Go service] -> [DuckDB /data/history.duckdb] + .csv.lz4 (parse + agg) | + | v + | [/v1/aggregates HTTP API] + v + [Upstash KV: ocb:hl-archive:v1] + | + v + [OCB Next.js on Vercel] +``` + +## Quick start + +```bash +docker build -t hl-archive . +docker run --rm -p 2114:2114 \ + -v "$PWD/data:/data" \ + -e HL_ARCHIVE_API_KEY=local-dev-key \ + -e HL_ARCHIVE_DB_PATH=/data/history.duckdb \ + -e HL_ARCHIVE_BUILDERS_FILE=/app/data/builders.json \ + hl-archive serve +``` + +Upstash is optional in dev: leave `UPSTASH_REDIS_REST_URL` / `UPSTASH_REDIS_REST_TOKEN` unset and the push step logs a warning and continues (see `script/upstash.go`). To get real data into the DB, run a backfill in a second shell: + +```bash +docker exec -it $(docker ps -qf ancestor=hl-archive) \ + hl-archive backfill --from 2025-08-01 --to 2025-08-03 +``` + +Then hit the API: + +```bash +curl http://localhost:2114/health +curl -H 'X-API-Key: local-dev-key' \ + 'http://localhost:2114/v1/aggregates?window=30d' +``` + +## CLI reference + +```text +hl-archive [flags] +``` + +| Subcommand | Purpose | +|---|---| +| `backfill` | Parse a date range from the CDN into DuckDB. Idempotent; days already in `processed_days` are skipped, replays of a day overwrite its rows. Flags: `--from YYYY-MM-DD --to YYYY-MM-DD [--workers 16]`. | +| `daily` | Parse J-1 (yesterday UTC) for every builder in `builders.json`, then push the snapshot to Upstash. Same as one cron tick. Takes no flags. | +| `rebuild` | Drop the aggregate tables and re-backfill the full coverage window (`HL_ARCHIVE_BACKFILL_FROM` to J-1). Destructive; requires `--confirm`. | +| `serve` | Long-running mode: HTTP API on `:2114`, Prom metrics on `/metrics`, in-process cron at `HL_ARCHIVE_CRON_HOUR` UTC. `HL_ARCHIVE_API_KEY` is required. Takes no flags. | +| `query` | Ad-hoc DuckDB query for one builder's per-day timeseries, prints JSON to stdout. Flags: `--builder 0x... [--days 30]`. Read-only. | + +All subcommands honour `LOG_LEVEL` (`debug` / `info` / `warn` / `error`). `info` is the default. + +## Environment variables + +| Name | Default | Required | Meaning | +|---|---|---|---| +| `HL_ARCHIVE_DB_PATH` | `/data/history.duckdb` | no | Path to the DuckDB file. Parent dir created on demand, must be writable. | +| `HL_ARCHIVE_BUILDERS_FILE` | `./data/builders.json` | no | Path to the builder registry JSON. | +| `HL_ARCHIVE_HTTP_ADDR` | `0.0.0.0:2114` | no | HTTP listen address for `serve`. | +| `HL_ARCHIVE_API_KEY` | (none) | yes for `serve` | Shared secret. `/v1/aggregates` requires `X-API-Key: `. `/health` and `/metrics` stay open. | +| `HL_ARCHIVE_CRON_HOUR` | `2` | no | Hour of day (UTC, 0-23) at which the in-process cron fires inside `serve`. | +| `HL_ARCHIVE_BACKFILL_FROM` | `2025-08-01` | no | Earliest day `rebuild` walks back to. | +| `HL_ARCHIVE_UPSTASH_KEY` | `ocb:hl-archive:v1` | no | Key the snapshot is written to. Bump the suffix when the JSON shape changes. | +| `UPSTASH_REDIS_REST_URL` | (none) | no, but push is skipped without it | Upstash REST endpoint, e.g. `https://us1-foo-12345.upstash.io`. | +| `UPSTASH_REDIS_REST_TOKEN` | (none) | no, but push is skipped without it | Upstash REST token (read+write). | +| `LOG_LEVEL` | `info` | no | `debug` / `info` / `warn` / `error`. | + +## API reference + +### `GET /health` + +Open, no auth. + +```bash +curl https://hl-archive-production.up.railway.app/health +``` + +```json +{ + "status": "ok", + "last_processed_day": "2026-06-24", + "lag_hours": 18.4, + "db_size_bytes": 41943040, + "builders_count": 104, + "days_count": 329, + "version": "1.0.0" +} +``` + +`status` flips to `degraded` when the store query errors or `lag_hours > 48`. HTTP code is always 200 (alerting is driven by `hl_archive_lag_hours`, not the HTTP status). + +### `GET /v1/aggregates` + +Auth-gated. Send `X-API-Key: `. Returns 401 otherwise. + +```bash +curl -H "X-API-Key: $HL_ARCHIVE_API_KEY" \ + 'https://hl-archive-production.up.railway.app/v1/aggregates?window=30d' +``` + +Query params: + +| Name | Values | Default | Meaning | +|---|---|---|---| +| `window` | `24h` / `7d` / `30d` / `90d` / `180d` / `1y` / `all` | `all` (400 days of timeseries) | Caps the per-builder daily timeseries length; the `windows` map is always populated for every window. | + +Response shape (`UpstashPayload`): + +```json +{ + "updated_at": "2026-06-25T02:00:01Z", + "builders": { + "0xb84168cf3be63c6b8dad05ff5d755e97432ff80b": { + "name": "Phantom", + "windows": { + "24h": { "volume_usd": 0.0, "fees_usd": 0.0, "fills": 0 }, + "7d": { "volume_usd": 0.0, "fees_usd": 0.0, "fills": 0 }, + "30d": { "volume_usd": 0.0, "fees_usd": 0.0, "fills": 0 }, + "90d": { "volume_usd": 0.0, "fees_usd": 0.0, "fills": 0 }, + "180d": { "volume_usd": 0.0, "fees_usd": 0.0, "fills": 0 }, + "1y": { "volume_usd": 0.0, "fees_usd": 0.0, "fills": 0 }, + "all": { "volume_usd": 0.0, "fees_usd": 0.0, "fills": 0 } + }, + "timeseries_daily": [ + { "day": "2026-05-26", "vol": 1234.56, "fees": 0.12, "fills": 8 } + ] + } + } +} +``` + +The keys of `builders` are the lowercased 0x addresses from `builders.json`. Derived ratios (effective fee bps, $ per user) live downstream in the OCB Next.js reader, not in this payload. + +### `GET /metrics` + +Standard Prometheus exposition, open, no auth. Scraped by the shared `openchainbench-monitoring` Prometheus at `hl-archive.railway.internal:2114`. + +## Prometheus metrics + +| Metric | Type | Labels | Meaning | +|---|---|---|---| +| `hl_archive_last_run_unix_seconds` | gauge | | Unix ts of the last completed daily-cron tick. | +| `hl_archive_files_processed_total` | counter | `source`, `result` | CDN files fetched. `source` is always `cdn` (single fetcher today); `result` is `ok` / `notfound` / `error`. The per-run provenance (daily vs backfill) lives in the `source` column of `processed_days`, not in this metric. | +| `hl_archive_db_size_bytes` | gauge | | Size of the DuckDB file. Climbs ~150 KB/day at steady state. | +| `hl_archive_builders_count` | gauge | | Distinct builders with at least one row in `builder_daily_aggregates`. | +| `hl_archive_days_count` | gauge | | Distinct days in `builder_daily_aggregates`. | +| `hl_archive_lag_hours` | gauge | | Hours between now and the most recent processed day. Should sit between 18 h and 30 h in steady state. | +| `hl_archive_upstash_push_duration_seconds` | histogram | | Wall-clock duration of the Upstash REST push. p95 < 1 s in steady state. | +| `hl_archive_cron_runs_total` | counter | `result` | Internal daily-cron firings. `result` is `ok` / `err`. | +| `hl_archive_http_requests_total` | counter | `path`, `code` | API request counts by route and HTTP status. | + +## Where the data goes + +### DuckDB schema + +```text +builder_daily_aggregates ++--------------+---------+--------------------------------------+ +| column | type | meaning | ++--------------+---------+--------------------------------------+ +| day | DATE | UTC day (PK part 1) | +| builder | VARCHAR | lowercased 0x... (PK part 2) | +| asset | VARCHAR | coin symbol (PK part 3) | +| volume_usd | DOUBLE | sum(px * sz) over the day | +| fees_usd | DOUBLE | sum(builder_fee) over the day | +| fill_count | BIGINT | row count | +| unique_users | BIGINT | count distinct user | ++--------------+---------+--------------------------------------+ +PRIMARY KEY (day, builder, asset) +INDEX idx_aggs_builder_day (builder, day) +INDEX idx_aggs_day (day) + +processed_days ++---------------+-----------+----------------------------+ +| column | type | meaning | ++---------------+-----------+----------------------------+ +| day | DATE | PK | +| processed_at | TIMESTAMP | when CommitDay finished | +| source | VARCHAR | "daily" / "backfill" | +| row_count | BIGINT | aggregate rows written | +| builder_count | INTEGER | builders that returned 200 | +| duration_ms | BIGINT | wall-clock of the day | ++---------------+-----------+----------------------------+ +``` + +`processed_days` is the source of truth for `last_processed_day` and `lag_hours`. The `builders` registry lives in `data/builders.json`, not in the DB; it is loaded once per process and on each `backfill` / `daily` invocation. + +### Upstash KV snapshot shape + +Key: `ocb:hl-archive:v1` (override with `HL_ARCHIVE_UPSTASH_KEY`). + +```json +{ + "updated_at": "2026-06-25T02:00:01Z", + "builders": { + "0x...": { + "name": "...", + "windows": { + "24h": { "volume_usd": 0.0, "fees_usd": 0.0, "fills": 0 }, + "7d": { "volume_usd": 0.0, "fees_usd": 0.0, "fills": 0 }, + "30d": { "volume_usd": 0.0, "fees_usd": 0.0, "fills": 0 }, + "90d": { "volume_usd": 0.0, "fees_usd": 0.0, "fills": 0 }, + "180d": { "volume_usd": 0.0, "fees_usd": 0.0, "fills": 0 }, + "1y": { "volume_usd": 0.0, "fees_usd": 0.0, "fills": 0 }, + "all": { "volume_usd": 0.0, "fees_usd": 0.0, "fills": 0 } + }, + "timeseries_daily": [ + { "day": "2026-05-26", "vol": 0.0, "fees": 0.0, "fills": 0 } + ] + } + } +} +``` + +Payload size grows linearly with the builder count and the timeseries length. With 104 builders and a 400-day cap on `timeseries_daily` the JSON is ~10 MB uncompressed, ~1.2 MB gzipped (Upstash REST gzips by default). + +## Why both `hl-archive` and `hyperliquid-frontends-local` exist + +| Aspect | `hyperliquid-frontends-local` | `hl-archive` | +|---|---|---| +| Source | Local hl-node L1 dump (`/mnt/hyperliquid/data/node_fills_by_block/hourly/`) | Public HL CDN (`stats-data.hyperliquid.xyz`) | +| Freshness | Sub-minute (last 24 h sliding) | J-1, daily refresh | +| Coverage | 24 h rolling | Aug 2025 to today (~11 months and growing) | +| Storage | In-memory only | DuckDB on disk | +| Host | OVH SGP (hl-node co-located) | Railway | +| Consumer | Live bench card (`/benchmarks/hyperliquid-frontends`) | Historical leaderboard + windowed views (24 h / 7 d / 30 d / 90 d / 180 d / 1 y / all) | +| Failure mode if down | Live card stale, history still served | History stale, live card unaffected | + +The two harnesses are intentionally decoupled: live and historical views have very different freshness, infra, and disk requirements, and pinning them together would force the live card to wait on a 24 h batch or the history to be pinned to OVH disk. Each side serves what it is good at. diff --git a/harnesses/hl-archive/RUNBOOK.md b/harnesses/hl-archive/RUNBOOK.md new file mode 100644 index 00000000..324e7d93 --- /dev/null +++ b/harnesses/hl-archive/RUNBOOK.md @@ -0,0 +1,190 @@ +# hl-archive runbook + +Operations manual for the `hl-archive` Railway service. Pair this with the [README](./README.md) for the user-facing contract and `DEPLOY.md` (in the same directory once shipped) for the deploy recipe. + +## Deploy a new version + +See `DEPLOY.md` in this directory. Short version: push to `dev`, let Railway auto-build the `hl-archive` service, watch the deploy log until you see `http server listening addr=0.0.0.0:2114`. Production cuts ship via the standard mobula-api `main` promotion. + +## Healthcheck + +```bash +curl -s https://hl-archive-production.up.railway.app/health | jq +``` + +A healthy response is: + +```json +{ + "status": "ok", + "last_processed_day": "2026-06-24", + "lag_hours": 18.4, + "db_size_bytes": 41943040, + "builders_count": 104, + "days_count": 329, + "version": "1.0.0" +} +``` + +Interpretation: + +| Field | Healthy range | Why it matters | +|---|---|---| +| `status` | `ok` | `degraded` means store query failed or lag > 48 h. | +| `db_size_bytes` | grows ~150 KB/day | A flat number for 48 h means writes are not landing. | +| `builders_count` | matches `data/builders.json` length (104) | Lower = registry not loaded or DB truncated. | +| `days_count` | climbs by one per cron tick | Drop = rebuild in progress or DB rolled back. | +| `last_processed_day` | yesterday UTC | Two days old = the cron tick missed. | +| `lag_hours` | 18 - 30 | > 30 = the `hl_archive_lag_hours` Prom alert should already be paging. | + +The HTTP status is always 200; rely on `hl_archive_lag_hours` and `hl_archive_cron_runs_total{result="err"}` for alerting, not on the response code. + +## Trigger a manual backfill + +A date range that overlaps days already in `processed_days` is skipped per-day; to force a re-parse, delete those rows from DuckDB first (see "Force-replay one day" below). Otherwise the call is idempotent. + +```bash +railway run --service hl-archive -- \ + hl-archive backfill --from 2025-08-01 --to 2026-06-28 +``` + +For one missing day: + +```bash +railway run --service hl-archive -- \ + hl-archive backfill --from 2026-06-23 --to 2026-06-23 +``` + +After the backfill, push a fresh snapshot. `daily` always targets J-1, so if the missing day is older, the snapshot you get already covers it once it is in the DB. To force a push: + +```bash +railway run --service hl-archive -- hl-archive daily +``` + +### Force-replay one day + +`backfill` skips days that already have a `processed_days` entry. To overwrite an existing day: + +```bash +railway run --service hl-archive -- \ + hl-archive query --builder 0xb84168cf3be63c6b8dad05ff5d755e97432ff80b --days 1 +# inspect what is there, then drop the row via a one-off SQL session: +railway run --service hl-archive -- bash -c \ + "echo \"DELETE FROM processed_days WHERE day='2026-06-23';\" \ + | duckdb /data/history.duckdb" +railway run --service hl-archive -- \ + hl-archive backfill --from 2026-06-23 --to 2026-06-23 +``` + +## Rebuild from scratch + +Destructive. Drops the aggregate tables in `/data/history.duckdb` and re-walks the CDN from `HL_ARCHIVE_BACKFILL_FROM` (default `2025-08-01`) to J-1. Expect ~30 min wall-clock at 16 workers and ~50 MB of DuckDB at the end of a full year. + +```bash +railway run --service hl-archive -- hl-archive rebuild --confirm +``` + +If the rebuild is itself failing (CDN throttle, LZ4 corruption), inspect the dead-letter file: + +```bash +railway run --service hl-archive -- cat /data/history.duckdb.failures.jsonl | tail +``` + +Each line is `{ts, builder, day, reason}`. Re-run the failed days individually with `backfill`. + +## Alerts and remediation + +### `hl_archive_lag_hours > 30` + +The configured cron hour (default 02:00 UTC) didn't run or didn't finish. + +1. Check `last_processed_day` in `/health`. If it is today minus 2 or older, the cron missed. +2. Look at the service logs in Railway for the most recent `cron next fire` and `cron tick` lines and any error after them. +3. Trigger the missed day manually: + + ```bash + railway run --service hl-archive -- hl-archive daily + ``` + + (`daily` always targets J-1; if J-2 is also missing, run `backfill --from J-2 --to J-2` first.) + +4. If `daily` succeeds, the alert clears within one scrape interval. If it fails, fall through to the next alert. + +### `hl_archive_files_processed_total{result="error"}` rising + +The CDN is throttling or has changed its CSV/LZ4 format. + +1. Tail the logs filtered on the parse layer: look for `cdn 5xx`, `cdn unavailable after retries`, or `csv header missing required columns`. +2. If the error is a 5xx or transient network failure, wait one cron tick: the next `daily` retries idempotently. +3. If the error is `csv header missing required columns`, the HL team changed the CSV schema. Fix the column index in `script/parse.go`, ship, rebuild. +4. Inspect the dead-letter for the affected (builder, day) pairs: + + ```bash + railway run --service hl-archive -- tail -n 50 /data/history.duckdb.failures.jsonl + ``` + +5. Once the fix is in, re-run those days with `backfill --from --to` covering the affected range (delete the offending `processed_days` rows first if they were stored as zero-row days). + +### Healthcheck reports `status: degraded` + +DuckDB lock, disk full, or `lag_hours > 48`. + +1. Check disk on the Railway volume: + + ```bash + railway run --service hl-archive -- df -h /data + ``` + + If `/data` is > 90 % full, expand the Railway volume. + +2. If disk is fine, check for a stale lock file: + + ```bash + railway run --service hl-archive -- ls -la /data + ``` + + A leftover `history.duckdb.wal` after a crash is normal; DuckDB replays it on next open. A separate `.lock` file from a hung process is not - restart the service to clear it. + +3. If the file itself is corrupt (rare), see "Disaster recovery" below. + +### `hl_archive_upstash_push_duration_seconds` p95 > 5 s + +Either Upstash is slow or the JSON payload has bloated past the REST endpoint's comfort zone. + +1. Confirm the size by running `daily` locally with `LOG_LEVEL=debug`; the `upstash push ok bytes=...` log line tells you the marshalled size. +2. If the byte count is unchanged and the Upstash status page is green, the issue is the underlying network. Re-deploy to force a fresh outbound connection pool. +3. If the count has jumped (someone added many builder addresses), audit `data/builders.json` and decide whether to keep them all. The snapshot grows linearly with the registry × timeseries length. +4. As a temporary mitigation, lower the timeseries cap by trimming `tsDays` (currently capped at 400) in `script/server.go` `buildPayload`. + +## Disaster recovery + +Lost DuckDB file (volume wiped, accidental `rm`, corrupted beyond repair): + +```bash +railway run --service hl-archive -- hl-archive rebuild --confirm +``` + +The rebuild fully recovers in ~30 min from the public CDN. No backup is needed because the source data is public and immutable. The `data/builders.json` registry IS the only piece of state that lives in this repo; keep it under version control. + +## Schema migration + +The Upstash payload shape is versioned in the key (`ocb:hl-archive:v1`). To change the snapshot JSON shape: + +1. Bump the key in `HL_ARCHIVE_UPSTASH_KEY` env to `ocb:hl-archive:v2` on the Railway service, but keep writing the old key too. Add a temporary second `PushUpstash` call inside `cmdDaily` that points at the legacy key. +2. Ship the OCB Next.js reader change behind a flag that reads `v2` with `v1` as fallback. +3. After 48 h of parallel writes, flip the Next.js to read `v2` only. +4. After another 48 h, remove the `v1` write from `hl-archive` and delete the key. + +For DuckDB schema changes (new column, type widening): add a `CREATE TABLE IF NOT EXISTS` / `ALTER TABLE` in `script/store.go` `schemaSQL`, ship, restart. Pure additive columns can ship without any orchestration; type changes or column renames need the same parallel-write dance as the KV key. + +## Capacity planning + +| Resource | Per year of coverage | Notes | +|---|---|---| +| DuckDB on disk | ~50 MB | Aggregated rows only, no raw fills. At 104 builders and 200 active coins the steady-state row count is ~7.5 M / year. | +| Upstash payload | ~10 MB JSON, ~1.2 MB gzipped | Linear in builders × timeseries length (cap 400 days). Each new builder adds ~10 KB. | +| Railway memory | < 256 MB resident | Streaming parser, only the per-day aggregator is held in RAM. | +| Railway CPU | spikes during cron tick, idle otherwise | 16 workers saturate ~2 vCPU for the ~3 min the daily run takes. | +| CDN bandwidth | < 100 MB / day at steady state | One LZ4 per (builder, day); most under 1 MB. Rebuild pulls ~10 GB. | + +Volume sizing: provision 2 GB on the Railway volume for `/data`. That gives years of headroom plus room for the `.wal` and dead-letter files. diff --git a/harnesses/hl-archive/cmd/hl-archive/main.go b/harnesses/hl-archive/cmd/hl-archive/main.go new file mode 100644 index 00000000..628f30f3 --- /dev/null +++ b/harnesses/hl-archive/cmd/hl-archive/main.go @@ -0,0 +1,22 @@ +// hl-archive — Hyperliquid builder fee/volume archive service. +// +// Entrypoint only: parses the subcommand off os.Args and forwards to +// the dispatcher in ../../script. Keeping main.go thin makes the +// subcommand handlers individually testable and lets the rest of the +// package live under a single import path that mirrors the OCB +// miniapp convention (script/*). +package main + +import ( + "fmt" + "os" + + hlarchive "hl-archive/script" +) + +func main() { + if err := hlarchive.Run(os.Args[1:]); err != nil { + fmt.Fprintf(os.Stderr, "[hl-archive] FATAL: %v\n", err) + os.Exit(1) + } +} diff --git a/harnesses/hl-archive/data/builders.json b/harnesses/hl-archive/data/builders.json new file mode 100644 index 00000000..fd726237 --- /dev/null +++ b/harnesses/hl-archive/data/builders.json @@ -0,0 +1,776 @@ +[ + { + "slug": "phantom-perps", + "name": "Phantom", + "address": "0xb84168cf3be63c6b8dad05ff5d755e97432ff80b", + "valid_from": "2026-05-31", + "notes": "Phantom wallet's Hyperliquid perps integration. Verified via CoinMarketMan HyperTracker registry + 200 OK on the public fills bucket on 2026-05-29 and 2026-05-30." + }, + { + "slug": "axiom", + "name": "Axiom", + "address": "0x1cc34f6af34653c515b47a83e1de70ba9b0cda1f", + "valid_from": "2026-05-31", + "notes": "Axiom trading terminal (Solana-native, expanding to HL). Verified via CMM registry + bucket 200 OK." + }, + { + "slug": "pvp-trade", + "name": "pvp.trade", + "address": "0x0cbf655b0d22ae71fba3a674b0e1c0c7e7f975af", + "valid_from": "2026-05-31", + "notes": "Social PvP trading rooms. Verified via CMM registry + bucket 200 OK + CMM per-builder URL." + }, + { + "slug": "insilico", + "name": "Insilico", + "address": "0x2868fc0d9786a740b491577a43502259efa78a39", + "valid_from": "2026-05-31", + "notes": "Insilico Terminal (institutional trading workstation). Verified via CMM registry + bucket 200 OK + CMM per-builder URL." + }, + { + "slug": "defiapp", + "name": "Defiapp", + "address": "0x1922810825c90f4270048b96da7b1803cd8609ef", + "valid_from": "2026-05-31", + "notes": "Defiapp HL frontend. Verified via bucket 200 OK with real data (sample test: 133 fills, $164K notional, 5 bps eff, 2026-05-29)." + }, + { + "slug": "metamask", + "name": "MetaMask", + "address": "0xe95a5e31904e005066614247d309e00d8ad753aa", + "valid_from": "2026-05-31", + "notes": "MetaMask HL integration (primary address). MetaMask is documented to operate a secondary address 0xea2c82b5aba243ab631c0ce151763d5e38df75b3 — aggregate manually if needed." + }, + { + "slug": "dexari", + "name": "Dexari", + "address": "0x7975cafdff839ed5047244ed3a0dd82a89866081", + "valid_from": "2026-05-31", + "notes": "Dexari HL frontend (previously called Dexterity in our internal docs, naming corrected after Flowscan cross-reference). Verified via CMM registry." + }, + { + "slug": "okto", + "name": "Okto", + "address": "0x05984fd37db96dc2a11a09519a8def556e80590b", + "valid_from": "2026-05-31", + "notes": "Okto HL integration. Tracks both documented builder addresses (primary + secondary). Either may receive routed flow depending on Okto's deployment state.", + "addresses": [ + "0x05984fd37db96dc2a11a09519a8def556e80590b", + "0x4fe1141b9066f3777f4bd4d4ac9d216173031dc1" + ] + }, + { + "slug": "trust-wallet", + "name": "Trust Wallet", + "address": "0x5af1b5f44207784dcb850bbb4143c5dcd1885f71", + "valid_from": "2026-06-02", + "notes": "Trust Wallet HL perps integration. Source: DefiLlama dimension-adapters factory/hyperliquid.ts dexsProtocols." + }, + { + "slug": "sushi", + "name": "Sushi", + "address": "0x12ee177db3ceafedc639d023a29cc8588db3a4b9", + "valid_from": "2026-06-02", + "notes": "Sushi perps frontend. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "dreamcash", + "name": "Dreamcash", + "address": "0x4950994884602d1b6c6d96e4fe30f58205c39395", + "valid_from": "2026-06-02", + "notes": "Dreamcash HL frontend. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "based-app", + "name": "Based", + "address": "0x1924b8561eef20e70ede628a296175d358be80e5", + "valid_from": "2026-06-02", + "notes": "Based app HL frontend. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "blink", + "name": "Blink", + "address": "0xc7bcb2eee9bbfbf875499960746bc52b2e1a75c6", + "valid_from": "2026-06-02", + "notes": "Blink perps. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "perpmate", + "name": "Perpmate", + "address": "0xe4fea748eca48f44b1e042775f0c2363be1a2d80", + "valid_from": "2026-06-02", + "notes": "Perpmate HL frontend. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "arena", + "name": "Arena", + "address": "0x7056a6bc0a962b6ca37bc5da4c4c5127c81b7af3", + "valid_from": "2026-06-02", + "notes": "Arena perps. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "minaraai", + "name": "MinaraAI", + "address": "0x5a3bc60b0a99a7f4fbf0d15554fa5fe88e7628c2", + "valid_from": "2026-06-02", + "notes": "MinaraAI HL frontend. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "apexliquid", + "name": "ApexLiquid", + "address": "0xe1f55f2f25884c2ddc86b6f7efa5f45b2ef04221", + "valid_from": "2026-06-02", + "notes": "ApexLiquid HL frontend. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "coin98", + "name": "Coin98", + "address": "0x3342ee6851ef0ec3cf42658c2be3b28a905271aa", + "valid_from": "2026-06-02", + "notes": "Coin98 wallet HL perps. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "coinpilot", + "name": "CoinPilot", + "address": "0xe9935bb291ab3603b4d7862e6f19315f759aa3a4", + "valid_from": "2026-06-02", + "notes": "CoinPilot HL frontend. Source: DefiLlama dimension-adapters factory/hyperliquid.ts. Note: same address listed for splashos-perps in DefiLlama config, likely a config bug there." + }, + { + "slug": "echosync", + "name": "Echosync", + "address": "0x831ad7eb3e600a3ab8df851ce27df8d8dd6b5d9c", + "valid_from": "2026-06-02", + "notes": "Echosync HL frontend. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "fomo", + "name": "FOMO", + "address": "0x2a2b6b093a9813fbd8cddae800c3d17d46460d17", + "valid_from": "2026-06-02", + "notes": "FOMO social trading app (relaunched HL builder, joined 2026-06-02). Address corrected 2026-06-12: the previous 0xb838e4d1 address from DefiLlama belongs to an older unrelated FOMO perps product. Verified against local node fills ($3.5K builder fees on 2026-06-12) + HyperTracker registry." + }, + { + "slug": "gemwallet", + "name": "Gem Wallet", + "address": "0x0d9dab1a248f63b0a48965ba8435e4de7497a3dc", + "valid_from": "2026-06-02", + "notes": "Gem Wallet HL perps. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "gtr-trade", + "name": "GTR Trade", + "address": "0x5ef4deeb76f87d979d0ddc8c51f5b4f65d1c972a", + "valid_from": "2026-06-02", + "notes": "GTR Trade HL frontend. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "hyprearn", + "name": "Hyprearn", + "address": "0x70cf605bb180daf00c3e2f1ca3df5bb602664452", + "valid_from": "2026-06-02", + "notes": "Hyprearn HL frontend. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "legend-trade", + "name": "Legend Trade", + "address": "0x4e65de9ca0abe3d36f7e3d7a7ce9f0dbe406a412", + "valid_from": "2026-06-02", + "notes": "Legend Trade HL frontend. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "katoshi", + "name": "Katoshi", + "address": "0x274e3cdb7bdc4805f41a07e3348243ba3e7e5b72", + "valid_from": "2026-06-02", + "notes": "Katoshi perps. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "metascalp", + "name": "Metascalp", + "address": "0xa9ab442f9dfe752dc74b666c41e7a0498baf8687", + "valid_from": "2026-06-02", + "notes": "Metascalp perps. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "moontrader", + "name": "Moontrader", + "address": "0x38b176c674cd9a3b97a59b0a7045ba26a13783cb", + "valid_from": "2026-06-02", + "notes": "Moontrader HL frontend. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "onekey", + "name": "OneKey", + "address": "0x9b12e858da780a96876e3018780cf0d83359b0bb", + "valid_from": "2026-06-02", + "notes": "OneKey wallet HL perps. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "pear", + "name": "Pear", + "address": "0xa47d4d99191db54a4829cdf3de2417e527c3b042", + "valid_from": "2026-06-02", + "notes": "Pear interface. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "rabby", + "name": "Rabby", + "address": "0xad9be64fd7a35d99a138b87cb212baefbcdcf045", + "valid_from": "2026-06-02", + "notes": "Rabby wallet HL perps. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "ranger-finance", + "name": "Ranger Finance", + "address": "0xf5bc9107916b91a3ea5966cd2e51655d21b7eb02", + "valid_from": "2026-06-02", + "notes": "Ranger Finance perps. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "senpi", + "name": "Senpi", + "address": "0x1368f4311db5807f7c7924d736adaeb83e47bafe", + "valid_from": "2026-06-02", + "notes": "Senpi perps. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "superx", + "name": "SuperX", + "address": "0x4ecd58def11dc3cadf7deb09f27da69d5475acb3", + "valid_from": "2026-06-02", + "notes": "SuperX HL frontend. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "supurr", + "name": "Supurr", + "address": "0x36be02a397e969e010ccbd7333f4169f66b8989f", + "valid_from": "2026-06-02", + "notes": "Supurr perps. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "unigox", + "name": "Unigox", + "address": "0xf8ead1ecc72dfbb87cdd7bf78450f7cf68d046a3", + "valid_from": "2026-06-02", + "notes": "Unigox perps. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "uxuy", + "name": "UXUY", + "address": "0x2e266a0f40e9f5bca48f5df1686aab10b1b68ec8", + "valid_from": "2026-06-02", + "notes": "UXUY HL frontend. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "wunder", + "name": "Wunder", + "address": "0x75982eb8b734b24b653b39e308489a428041f162", + "valid_from": "2026-06-02", + "notes": "Wunder perps. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "grider", + "name": "Grider", + "address": "0x0176337c97bb884b8ac4be2276a5c779ab1156b9", + "valid_from": "2026-06-02", + "notes": "Grider perps. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "tradoor", + "name": "Tradoor", + "address": "0x92345453ce2000642d7d4ceeae4fccc6c2e41d23", + "valid_from": "2026-06-02", + "notes": "Tradoor perps. Source: DefiLlama dimension-adapters factory/hyperliquid.ts." + }, + { + "slug": "bullpenfi", + "name": "BullpenFi", + "address": "0x4c8731897503f86a2643959cbaa1e075e84babb7", + "valid_from": "2026-06-02", + "notes": "BullpenFi perps. Source: DefiLlama dimension-adapters factory/hyperliquid.ts feesProtocols." + }, + { + "slug": "dexly-trade", + "name": "Dexly Trade", + "address": "0x22047776933bc123d0602ed17aaf0d2f5647df0c", + "valid_from": "2026-06-02", + "notes": "Dexly Trade. Source: DefiLlama dimension-adapters factory/hyperliquid.ts feesProtocols." + }, + { + "slug": "hyperdash", + "name": "Hyperdash", + "address": "0xe966a12bf7b93838096e4519a684519ab22df618", + "valid_from": "2026-06-02", + "notes": "Hyperdash HL frontend. Source: DefiLlama dimension-adapters factory/hyperliquid.ts feesProtocols." + }, + { + "slug": "infinex", + "name": "Infinex", + "address": "0xcf56dd84ed85eb4929e0a76a0f2f04049b4ffc1a", + "valid_from": "2026-06-02", + "notes": "Infinex perps. Source: DefiLlama dimension-adapters factory/hyperliquid.ts feesProtocols." + }, + { + "slug": "liminal", + "name": "Liminal", + "address": "0x7e1830b1796b01f2f6a7118d50d4d02491421f32", + "valid_from": "2026-06-02", + "notes": "Liminal perps. Source: DefiLlama dimension-adapters factory/hyperliquid.ts feesProtocols." + }, + { + "slug": "liquid-perps", + "name": "Liquid Perps", + "address": "0x6d4e7f472e6a491b98cbeed327417e310ae8ce48", + "valid_from": "2026-06-02", + "notes": "Liquid Perps. Source: DefiLlama dimension-adapters factory/hyperliquid.ts feesProtocols." + }, + { + "slug": "lit-trade", + "name": "Lit Trade", + "address": "0x24a747628494231347f4f6aead2ec14f50bcc8b7", + "valid_from": "2026-06-02", + "notes": "Lit Trade. Source: DefiLlama dimension-adapters factory/hyperliquid.ts feesProtocols." + }, + { + "slug": "lootbase", + "name": "Lootbase", + "address": "0x3e0ef9ad4096c30acefbf7a996f4c19edd071286", + "valid_from": "2026-06-02", + "notes": "Lootbase HL frontend. Source: DefiLlama dimension-adapters factory/hyperliquid.ts feesProtocols." + }, + { + "slug": "mass-dot-money", + "name": "Mass.money", + "address": "0xf944069b489f1ebff4c3c6a6014d58cbef7c7009", + "valid_from": "2026-06-02", + "notes": "Mass.money perps. Source: DefiLlama dimension-adapters factory/hyperliquid.ts feesProtocols." + }, + { + "slug": "moonbot", + "name": "Moonbot", + "address": "0xb84c7fb41ee7d8781e2b0d59eed2accd2ae99533", + "valid_from": "2026-06-02", + "notes": "Moonbot. Source: DefiLlama dimension-adapters factory/hyperliquid.ts feesProtocols." + }, + { + "slug": "rainbow", + "name": "Rainbow", + "address": "0x60dc8e3dad2e4e0738e813b9cb09b9c00b5e0fc9", + "valid_from": "2026-06-02", + "notes": "Rainbow wallet HL perps. Source: DefiLlama dimension-adapters factory/hyperliquid.ts feesProtocols." + }, + { + "slug": "supercexy", + "name": "SuperCEXy", + "address": "0x0000000bfbf4c62c43c2e71ef0093f382bf7a7b4", + "valid_from": "2026-06-02", + "notes": "SuperCEXy. Source: DefiLlama dimension-adapters factory/hyperliquid.ts feesProtocols." + }, + { + "slug": "superstack", + "name": "Superstack", + "address": "0xcdb943570bcb48a6f1d3228d0175598fea19e87b", + "valid_from": "2026-06-02", + "notes": "Superstack. Source: DefiLlama dimension-adapters factory/hyperliquid.ts feesProtocols." + }, + { + "slug": "wallet-v", + "name": "Wallet V", + "address": "0x68c68ba58f50bdbe5c4a6faf0186b140eab2b764", + "valid_from": "2026-06-02", + "notes": "Wallet V. Source: DefiLlama dimension-adapters factory/hyperliquid.ts feesProtocols." + }, + { + "slug": "xtrade-protocol", + "name": "xTrade Protocol", + "address": "0xa58d3d31f09d75bd92ae2ef277e785b2ebb83b77", + "valid_from": "2026-06-02", + "notes": "xTrade Protocol perps. Source: DefiLlama dimension-adapters factory/hyperliquid.ts feesProtocols." + }, + { + "slug": "taco-trade", + "name": "Taco Trade", + "address": "0xf5b79dea3d8cf3efa95e8176ebd885634d869f51", + "valid_from": "2026-06-02", + "notes": "Taco Trade. Source: DefiLlama dimension-adapters factory/hyperliquid.ts feesProtocols." + }, + { + "slug": "silhouette", + "name": "Silhouette", + "address": "0x5d2c2bd98f10616771d7b5124ad2090ba72aa43c", + "valid_from": "2026-06-02", + "notes": "Silhouette (silhouette-naked). Source: DefiLlama dimension-adapters factory/hyperliquid.ts feesProtocols." + }, + { + "slug": "tread-fi", + "name": "Tread.fi", + "address": "0x999a4b5f268a8fbf33736feff360d462ad248dbf", + "valid_from": "2026-06-02", + "notes": "Tread.fi HL frontend. Source: DefiLlama dimension-adapters dexs/treadfi-perps.ts." + }, + { + "slug": "flowbot", + "name": "FlowBot", + "address": "0xb5d19a1f92fcd5bfdd154d16793bb394f246cb36", + "valid_from": "2026-06-02", + "notes": "FlowBot HL frontend. Source: DefiLlama dimension-adapters dexs/flowbot-perps.ts." + }, + { + "slug": "nautilus-trader", + "name": "Nautilus Trader", + "address": "0x0c8d970c462726e014ad36f6c5a63e99db48a8e7", + "valid_from": "2026-06-02", + "notes": "Nautilus Trader (Nautech Systems). Identified via behavioral pattern (heavy MU 85%, 0 builder fee, low user count) cross-referenced with GitHub code search." + }, + { + "slug": "0x7cc0fd2b", + "name": "0x7cc0…e781", + "address": "0x7cc0fd2b76835ab96aa4a3501a9f65e75677e781", + "valid_from": "2026-06-11", + "notes": "Unidentified active builder address, surfaced by a 24h node-fill scan on 2026-06-10 (~$5703/24h fees, 2514 unique users). Not labeled by DefiLlama dimension-adapters, Dwellir or ASXN at add time. Rename when the operating frontend is identified." + }, + { + "slug": "0x557edb25", + "name": "0x557e…3c81", + "address": "0x557edb253b1d7ed5f15b248a5a3fd919fa5d3c81", + "valid_from": "2026-06-11", + "notes": "Unidentified active builder address, surfaced by a 24h node-fill scan on 2026-06-10 (~$4157/24h fees, 2771 unique users). Not labeled by DefiLlama dimension-adapters, Dwellir or ASXN at add time. Rename when the operating frontend is identified." + }, + { + "slug": "0x446fbc72", + "name": "0x446f…d6c6", + "address": "0x446fbc72549fdcf656a5165f97b354e5a0e7d6c6", + "valid_from": "2026-06-11", + "notes": "Unidentified active builder address, surfaced by a 24h node-fill scan on 2026-06-10 (~$1836/24h fees, 22 unique users). Not labeled by DefiLlama dimension-adapters, Dwellir or ASXN at add time. Rename when the operating frontend is identified." + }, + { + "slug": "0xf85a6185", + "name": "0xf85a…5688", + "address": "0xf85a61857c0682b9b59d562310df106b4f785688", + "valid_from": "2026-06-11", + "notes": "Unidentified active builder address, surfaced by a 24h node-fill scan on 2026-06-10 (~$1805/24h fees, 24 unique users). Not labeled by DefiLlama dimension-adapters, Dwellir or ASXN at add time. Rename when the operating frontend is identified." + }, + { + "slug": "0xb290f2f3", + "name": "0xb290…c34a", + "address": "0xb290f2f3fad4e540d0550985951cdad2711ac34a", + "valid_from": "2026-06-11", + "notes": "Unidentified active builder address, surfaced by a 24h node-fill scan on 2026-06-10 (~$1641/24h fees, 57 unique users). Not labeled by DefiLlama dimension-adapters, Dwellir or ASXN at add time. Rename when the operating frontend is identified." + }, + { + "slug": "0xa1fcd6e2", + "name": "0xa1fc…bf4d", + "address": "0xa1fcd6e2356c445d38ec1e25eae634e1104abf4d", + "valid_from": "2026-06-11", + "notes": "Unidentified active builder address, surfaced by a 24h node-fill scan on 2026-06-10 (~$1181/24h fees, 28 unique users). Not labeled by DefiLlama dimension-adapters, Dwellir or ASXN at add time. Rename when the operating frontend is identified." + }, + { + "slug": "0x53a19541", + "name": "0x53a1…fcd1", + "address": "0x53a1954188fc9bf2edb45b94450100507b92fcd1", + "valid_from": "2026-06-11", + "notes": "Unidentified active builder address, surfaced by a 24h node-fill scan on 2026-06-10 (~$1132/24h fees, 56 unique users). Not labeled by DefiLlama dimension-adapters, Dwellir or ASXN at add time. Rename when the operating frontend is identified." + }, + { + "slug": "0xdbc27ea7", + "name": "0xdbc2…97a6", + "address": "0xdbc27ea7aa99274026404b2fa21114815d9997a6", + "valid_from": "2026-06-11", + "notes": "Unidentified active builder address, surfaced by a 24h node-fill scan on 2026-06-10 (~$938/24h fees, 123 unique users). Not labeled by DefiLlama dimension-adapters, Dwellir or ASXN at add time. Rename when the operating frontend is identified." + }, + { + "slug": "0xea2c82b5", + "name": "0xea2c…75b3", + "address": "0xea2c82b5aba243ab631c0ce151763d5e38df75b3", + "valid_from": "2026-06-11", + "notes": "Unidentified active builder address, surfaced by a 24h node-fill scan on 2026-06-10 (~$880/24h fees, 121 unique users). Not labeled by DefiLlama dimension-adapters, Dwellir or ASXN at add time. Rename when the operating frontend is identified." + }, + { + "slug": "0x49791d46", + "name": "0x4979…37b8", + "address": "0x49791d4667e310abe173bc4989aed4f0bed837b8", + "valid_from": "2026-06-11", + "notes": "Unidentified active builder address, surfaced by a 24h node-fill scan on 2026-06-10 (~$851/24h fees, 4 unique users). Not labeled by DefiLlama dimension-adapters, Dwellir or ASXN at add time. Rename when the operating frontend is identified." + }, + { + "slug": "0x9f83fe01", + "name": "0x9f83…31d9", + "address": "0x9f83fe01f4a62d44e8ca471e2eeb42b5c05531d9", + "valid_from": "2026-06-11", + "notes": "Unidentified active builder address, surfaced by a 24h node-fill scan on 2026-06-10 (~$733/24h fees, 135 unique users). Not labeled by DefiLlama dimension-adapters, Dwellir or ASXN at add time. Rename when the operating frontend is identified." + }, + { + "slug": "0x42f32260", + "name": "0x42f3…f992", + "address": "0x42f3226007290b02c5a0b15bccbb1ba6df04f992", + "valid_from": "2026-06-11", + "notes": "Unidentified active builder address, surfaced by a 24h node-fill scan on 2026-06-10 (~$690/24h fees, 99 unique users). Not labeled by DefiLlama dimension-adapters, Dwellir or ASXN at add time. Rename when the operating frontend is identified." + }, + { + "slug": "0xc95d92dc", + "name": "0xc95d…1aca", + "address": "0xc95d92dc8ca672abcc8aaec49a94559bbf481aca", + "valid_from": "2026-06-11", + "notes": "Unidentified active builder address, surfaced by a 24h node-fill scan on 2026-06-10 (~$543/24h fees, 17 unique users). Not labeled by DefiLlama dimension-adapters, Dwellir or ASXN at add time. Rename when the operating frontend is identified." + }, + { + "slug": "0xdf39721d", + "name": "0xdf39…4750", + "address": "0xdf39721d2c4fc0fedc92c68e3879ba594bb64750", + "valid_from": "2026-06-11", + "notes": "Unidentified active builder address, surfaced by a 24h node-fill scan on 2026-06-10 (~$525/24h fees, 84 unique users). Not labeled by DefiLlama dimension-adapters, Dwellir or ASXN at add time. Rename when the operating frontend is identified." + }, + { + "slug": "0x40e9d9fe", + "name": "0x40e9…5260", + "address": "0x40e9d9feba3df27e1fb9a924264bf775230d5260", + "valid_from": "2026-06-11", + "notes": "Unidentified active builder address, surfaced by a 24h node-fill scan on 2026-06-10 (~$513/24h fees, 13 unique users). Not labeled by DefiLlama dimension-adapters, Dwellir or ASXN at add time. Rename when the operating frontend is identified." + }, + { + "slug": "dextrabot", + "name": "Dextrabot", + "address": "0x49ae63056b3a0be0b166813ee687309ab653c07c", + "valid_from": "2025-02-19", + "notes": "Source: HyperTracker public builder registry (refCode Dextrabot, joined 2025-02-19). Batch coverage audit 2026-06-12 against local node fills." + }, + { + "slug": "kinto", + "name": "Kinto", + "address": "0xc1f4d15c16a1f3555e0a5f7aefd1e17ad4aaf40b", + "valid_from": "2024-12-25", + "notes": "Source: HyperTracker public builder registry (refCode Kinto, joined 2024-12-25). Batch coverage audit 2026-06-12 against local node fills." + }, + { + "slug": "hypersignals", + "name": "HyperSignals", + "address": "0x8af3545a3988b7a46f96f9f1ae40c0e64fa493c2", + "valid_from": "2025-06-18", + "notes": "Source: HyperTracker public builder registry (refCode HyperSignals, joined 2025-06-18). Batch coverage audit 2026-06-12 against local node fills." + }, + { + "slug": "ccxt", + "name": "CCXT", + "address": "0x6530512a6c89c7cfcebc3ba7fcd9ada5f30827a6", + "valid_from": "2025-07-16", + "notes": "Source: HyperTracker public builder registry (refCode CCXT1, joined 2025-07-16). Batch coverage audit 2026-06-12 against local node fills." + }, + { + "slug": "vibeliquid", + "name": "VibeLiquid", + "address": "0x4c13d871aa1862a3c60407bb9e01b20bf1e2fede", + "valid_from": "2026-02-11", + "notes": "Source: HyperTracker public builder registry (refCode VibeLiquid, joined 2026-02-11). Batch coverage audit 2026-06-12 against local node fills." + }, + { + "slug": "splash", + "name": "Splash", + "address": "0x3f24962739e6d703942dc2456e7c51c8d0ca4b70", + "valid_from": "2025-06-04", + "notes": "Source: HyperTracker public builder registry (refCode Splash, joined 2025-06-04). Batch coverage audit 2026-06-12 against local node fills." + }, + { + "slug": "vooi", + "name": "VOOI", + "address": "0xbe622f92438ae55b12908b01eeace15d98ed1eec", + "valid_from": "2024-10-30", + "notes": "Source: HyperTracker public builder registry (refCode VOOI, joined 2024-10-30). Batch coverage audit 2026-06-12 against local node fills." + }, + { + "slug": "hyperx", + "name": "HyperX", + "address": "0xc74812f67eddaf2f3aed6e061eaa9168b36d7ea1", + "valid_from": "2025-04-16", + "notes": "Source: HyperTracker public builder registry (refCode HyperX, joined 2025-04-16). Batch coverage audit 2026-06-12 against local node fills." + }, + { + "slug": "miracle", + "name": "Miracle", + "address": "0x5eb46bfbf7c6004b59d67e56749e89e83c2caf82", + "valid_from": "2025-09-03", + "notes": "Source: HyperTracker public builder registry (refCode Miracle, joined 2025-09-03). Batch coverage audit 2026-06-12 against local node fills." + }, + { + "slug": "xbit", + "name": "XBIT", + "address": "0x0c322f69ab8d0544be3cfd54424762a4251806c5", + "valid_from": "2025-08-16", + "notes": "Source: HyperTracker public builder registry (refCode XBIT, joined 2025-08-16). Batch coverage audit 2026-06-12 against local node fills." + }, + { + "slug": "tuleep-trade", + "name": "tuleep.trade", + "address": "0x00000000617c4307d352e8c1720a9f29d01c3d62", + "valid_from": "2025-05-28", + "notes": "Source: HyperTracker public builder registry (refCode TULEEPTRADE, joined 2025-05-28). Batch coverage audit 2026-06-12 against local node fills." + }, + { + "slug": "slash", + "name": "Slash", + "address": "0xaf69b1587b87c78409e5a20c3fd5f1ca386fd350", + "valid_from": "2025-02-12", + "notes": "Source: HyperTracker public builder registry (refCode SLASH, joined 2025-02-12). Batch coverage audit 2026-06-12 against local node fills." + }, + { + "slug": "shax", + "name": "SHAX", + "address": "0x046a3ae662e8603d5eaeb74910c74e57a3ccd7ec", + "valid_from": "2026-04-08", + "notes": "Source: HyperTracker public builder registry (refCode SHAX, joined 2026-04-08). Batch coverage audit 2026-06-12 against local node fills." + }, + { + "slug": "aura-money", + "name": "Aura", + "address": "0xee41f05496bc30dbd29c96bc31283c7e9f062192", + "valid_from": "2025-07-09", + "notes": "Source: HyperTracker public builder registry (refCode AURADOTMONEY, joined 2025-07-09). Batch coverage audit 2026-06-12 against local node fills." + }, + { + "slug": "cro-trade", + "name": "cro.trade", + "address": "0x008adf65b8c404e8bba73f18671306066643761f", + "valid_from": "2026-03-18", + "notes": "Source: HyperTracker public builder registry (refCode cro.trade, joined 2026-03-18). Batch coverage audit 2026-06-12 against local node fills." + }, + { + "slug": "owlyfi", + "name": "Owly.fi", + "address": "0x2e2e7c7696134f740aea7242a55b55d5cf769fab", + "valid_from": "2025-12-17", + "notes": "Source: HyperTracker public builder registry (refCode OWLYFI, joined 2025-12-17). Batch coverage audit 2026-06-12 against local node fills." + }, + { + "slug": "topdog", + "name": "TopDog", + "address": "0xaef63e8441987a5e9bf1d37ebb61d8855f405a98", + "valid_from": "2025-04-09", + "notes": "Source: HyperTracker public builder registry (refCode TopDog, joined 2025-04-09). Batch coverage audit 2026-06-12 against local node fills." + }, + { + "slug": "liquid8", + "name": "Liquid8", + "address": "0x7151a036313eee8aa9bc45d0969ca0e1637aad3c", + "valid_from": "2025-03-05", + "notes": "Source: HyperTracker public builder registry (refCode LIQUID8, joined 2025-03-05). Batch coverage audit 2026-06-12 against local node fills." + }, + { + "slug": "goodcryptox", + "name": "goodcryptoX", + "address": "0xff09853a49dde85a8e7eab58abf94018fbe76116", + "valid_from": "2025-10-15", + "notes": "Source: HyperTracker public builder registry (refCode GOODCRYPTOX, joined 2025-10-15). Batch coverage audit 2026-06-12 against local node fills." + }, + { + "slug": "markets-mobile", + "name": "Markets Mobile", + "address": "0x2af94a24e1f744a8e251b4996283ffb4657e915d", + "valid_from": "2025-12-03", + "notes": "Source: HyperTracker public builder registry (refCode Markets Mobile, joined 2025-12-03). Batch coverage audit 2026-06-12 against local node fills." + }, + { + "slug": "origami-tech", + "name": "Origami Tech", + "address": "0x9b451f8941240db8bedc99bff8917a2ed9550074", + "valid_from": "2025-11-07", + "notes": "Source: HyperTracker public builder registry (refCode Origami Tech, joined 2025-11-07). Batch coverage audit 2026-06-12 against local node fills." + }, + { + "slug": "cwallet", + "name": "Cwallet", + "address": "0xb977b6625dfe3d26eefa4ac6f99ada6546586962", + "valid_from": "2025-08-13", + "notes": "Source: HyperTracker public builder registry (refCode Cwallet, joined 2025-08-13). Batch coverage audit 2026-06-12 against local node fills." + }, + { + "slug": "cipher", + "name": "Cipher", + "address": "0x32f6940795d6d484a6d29b6b628ebaff80a0c779", + "valid_from": "2025-04-16", + "notes": "Source: HyperTracker public builder registry (refCode Cipher, joined 2025-04-16). Batch coverage audit 2026-06-12 against local node fills." + }, + { + "slug": "stryx", + "name": "STRYX", + "address": "0x60bce92a6f6685a8e7cc721ed025c7b848dd52c2", + "valid_from": "2026-03-04", + "notes": "Source: HyperTracker public builder registry (refCode STRYX, joined 2026-03-04). Batch coverage audit 2026-06-12 against local node fills." + }, + { + "slug": "perpswld", + "name": "PerpsWLD", + "address": "0xbeef9cf5b817b1fb533b278f9a8754ec2b3745be", + "valid_from": "2025-12-10", + "notes": "Source: HyperTracker public builder registry (refCode PERPSWLD, joined 2025-12-10). Batch coverage audit 2026-06-12 against local node fills." + }, + { + "slug": "onchaincc", + "name": "Onchain.cc", + "address": "0x64c06812904d342367ad155d7a8f3b7c7fb27f5f", + "valid_from": "2026-03-13", + "notes": "Source: HyperTracker public builder registry (refCode ONCHAINCC, joined 2026-03-13). Batch coverage audit 2026-06-12 against local node fills." + }, + { + "slug": "kucoin-web3", + "name": "KuCoin Web3", + "address": "0x17e133500905dc6e85d9802c9e9b9120966aba27", + "valid_from": "2025-11-18", + "notes": "Source: HyperTracker public builder registry (refCode KUCOINWEB3, joined 2025-11-18). Batch coverage audit 2026-06-12 against local node fills." + }, + { + "slug": "vergex", + "name": "VergeX", + "address": "0x891dc6f05ad47a3c1a05da55e7a7517971faaf0d", + "valid_from": "2025-11-12", + "notes": "Source: HyperTracker public builder registry (refCode VergeX, joined 2025-11-12). Batch coverage audit 2026-06-12 against local node fills." + }, + { + "slug": "nansen", + "name": "Nansen", + "address": "0x93053f1e7a5efeda532fe69cbbe43cbec3a0f13f", + "valid_from": "2026-05-06", + "notes": "Nansen perps (refCode NSN on HyperTracker; nansen-perps in DefiLlama factory/hyperliquid.ts, start 2026-05-04). Identified during 2026-06-12 metadata audit." + }, + { + "slug": "defi-saver", + "name": "DeFi Saver", + "address": "0x40e9d9feba3df27e1fb9a924264bf775230d5260", + "valid_from": "2026-06-28", + "notes": "DeFi Saver HL perps integration. Verified via HL referral code DFS (53 referrals) + blog.defisaver.com 2026 announcement." + }, + { + "slug": "unitywallet", + "name": "UnityWallet", + "address": "0x49791d4667e310abe173bc4989aed4f0bed837b8", + "valid_from": "2026-06-28", + "notes": "UnityWallet self-custodial wallet HL integration. Verified via HL referral code UNITYWALLET (58 referrals) + unitywallet.com." + }, + { + "slug": "invo", + "name": "Invo", + "address": "0x557edb253b1d7ed5f15b248a5a3fd919fa5d3c81", + "valid_from": "2026-06-28", + "notes": "Invo social trading app (invoapp.com) on Hyperliquid. Verified via HL referral code INVO (25794 referrals)." + }, + { + "slug": "marsgo", + "name": "MarsGO", + "address": "0xa1fcd6e2356c445d38ec1e25eae634e1104abf4d", + "valid_from": "2026-06-28", + "notes": "MarsGO Telegram crypto app HL integration. Verified via HL referral code MARSGO (89 referrals) + findmini.app/marsgo_bot listing." + }, + { + "slug": "bitget-wallet", + "name": "Bitget Wallet", + "address": "0xdf39721d2c4fc0fedc92c68e3879ba594bb64750", + "valid_from": "2026-06-28", + "notes": "Bitget Wallet HL perps integration (Dec 2025 launch). Verified via HL referral code BITGETWALLET (3676 referrals)." + }, + { + "slug": "metamask-alt", + "name": "MetaMask (alt)", + "address": "0xea2c82b5aba243ab631c0ce151763d5e38df75b3", + "valid_from": "2026-06-28", + "notes": "MetaMask secondary HL builder address, documented alongside primary 0xe95a5e31." + } +] diff --git a/harnesses/hl-archive/go.mod b/harnesses/hl-archive/go.mod new file mode 100644 index 00000000..54dc21f9 --- /dev/null +++ b/harnesses/hl-archive/go.mod @@ -0,0 +1,42 @@ +module hl-archive + +go 1.24.0 + +require ( + github.com/marcboeker/go-duckdb/v2 v2.4.3 + github.com/pierrec/lz4/v4 v4.1.22 + github.com/prometheus/client_golang v1.23.2 +) + +require ( + github.com/apache/arrow-go/v18 v18.4.1 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/duckdb/duckdb-go-bindings v0.1.21 // indirect + github.com/duckdb/duckdb-go-bindings/darwin-amd64 v0.1.21 // indirect + github.com/duckdb/duckdb-go-bindings/darwin-arm64 v0.1.21 // indirect + github.com/duckdb/duckdb-go-bindings/linux-amd64 v0.1.21 // indirect + github.com/duckdb/duckdb-go-bindings/linux-arm64 v0.1.21 // indirect + github.com/duckdb/duckdb-go-bindings/windows-amd64 v0.1.21 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/google/flatbuffers v25.2.10+incompatible // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/marcboeker/go-duckdb/arrowmapping v0.0.21 // indirect + github.com/marcboeker/go-duckdb/mapping v0.0.21 // 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 + github.com/zeebo/xxh3 v1.0.2 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect + golang.org/x/mod v0.27.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/tools v0.36.0 // indirect + golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect + google.golang.org/protobuf v1.36.8 // indirect +) diff --git a/harnesses/hl-archive/go.sum b/harnesses/hl-archive/go.sum new file mode 100644 index 00000000..6c226f2b --- /dev/null +++ b/harnesses/hl-archive/go.sum @@ -0,0 +1,105 @@ +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/apache/arrow-go/v18 v18.4.1 h1:q/jVkBWCJOB9reDgaIZIdruLQUb1kbkvOnOFezVH1C4= +github.com/apache/arrow-go/v18 v18.4.1/go.mod h1:tLyFubsAl17bvFdUAy24bsSvA/6ww95Iqi67fTpGu3E= +github.com/apache/thrift v0.22.0 h1:r7mTJdj51TMDe6RtcmNdQxgn9XcyfGDOzegMDRg47uc= +github.com/apache/thrift v0.22.0/go.mod h1:1e7J/O1Ae6ZQMTYdy9xa3w9k+XHWPfRvdPyJeynQ+/g= +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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/duckdb/duckdb-go-bindings v0.1.21 h1:bOb/MXNT4PN5JBZ7wpNg6hrj9+cuDjWDa4ee9UdbVyI= +github.com/duckdb/duckdb-go-bindings v0.1.21/go.mod h1:pBnfviMzANT/9hi4bg+zW4ykRZZPCXlVuvBWEcZofkc= +github.com/duckdb/duckdb-go-bindings/darwin-amd64 v0.1.21 h1:Sjjhf2F/zCjPF53c2VXOSKk0PzieMriSoyr5wfvr9d8= +github.com/duckdb/duckdb-go-bindings/darwin-amd64 v0.1.21/go.mod h1:Ezo7IbAfB8NP7CqPIN8XEHKUg5xdRRQhcPPlCXImXYA= +github.com/duckdb/duckdb-go-bindings/darwin-arm64 v0.1.21 h1:IUk0FFUB6dpWLhlN9hY1mmdPX7Hkn3QpyrAmn8pmS8g= +github.com/duckdb/duckdb-go-bindings/darwin-arm64 v0.1.21/go.mod h1:eS7m/mLnPQgVF4za1+xTyorKRBuK0/BA44Oy6DgrGXI= +github.com/duckdb/duckdb-go-bindings/linux-amd64 v0.1.21 h1:Qpc7ZE3n6Nwz30KTvaAwI6nGkXjXmMxBTdFpC8zDEYI= +github.com/duckdb/duckdb-go-bindings/linux-amd64 v0.1.21/go.mod h1:1GOuk1PixiESxLaCGFhag+oFi7aP+9W8byymRAvunBk= +github.com/duckdb/duckdb-go-bindings/linux-arm64 v0.1.21 h1:eX2DhobAZOgjXkh8lPnKAyrxj8gXd2nm+K71f6KV/mo= +github.com/duckdb/duckdb-go-bindings/linux-arm64 v0.1.21/go.mod h1:o7crKMpT2eOIi5/FY6HPqaXcvieeLSqdXXaXbruGX7w= +github.com/duckdb/duckdb-go-bindings/windows-amd64 v0.1.21 h1:hhziFnGV7mpA+v5J5G2JnYQ+UWCCP3NQ+OTvxFX10D8= +github.com/duckdb/duckdb-go-bindings/windows-amd64 v0.1.21/go.mod h1:IlOhJdVKUJCAPj3QsDszUo8DVdvp1nBFp4TUJVdw99s= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q= +github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +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/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/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4= +github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE= +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/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +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/marcboeker/go-duckdb/arrowmapping v0.0.21 h1:geHnVjlsAJGczSWEqYigy/7ARuD+eBtjd0kLN80SPJQ= +github.com/marcboeker/go-duckdb/arrowmapping v0.0.21/go.mod h1:flFTc9MSqQCh2Xm62RYvG3Kyj29h7OtsTb6zUx1CdK8= +github.com/marcboeker/go-duckdb/mapping v0.0.21 h1:6woNXZn8EfYdc9Vbv0qR6acnt0TM1s1eFqnrJZVrqEs= +github.com/marcboeker/go-duckdb/mapping v0.0.21/go.mod h1:q3smhpLyv2yfgkQd7gGHMd+H/Z905y+WYIUjrl29vT4= +github.com/marcboeker/go-duckdb/v2 v2.4.3 h1:bHUkphPsAp2Bh/VFEdiprGpUekxBNZiWWtK+Bv/ljRk= +github.com/marcboeker/go-duckdb/v2 v2.4.3/go.mod h1:taim9Hktg2igHdNBmg5vgTfHAlV26z3gBI0QXQOcuyI= +github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpspGNG7Z948v4n35fFGB3RR3G/ry4FWs= +github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY= +github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI= +github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE= +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/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= +github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/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= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +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/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= +golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +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/hl-archive/railway.toml b/harnesses/hl-archive/railway.toml new file mode 100644 index 00000000..f141012f --- /dev/null +++ b/harnesses/hl-archive/railway.toml @@ -0,0 +1,79 @@ +# hl-archive — Railway service config. +# +# Build context is the monorepo root so the Dockerfile can `COPY data/...` +# from the miniapp tree without leaking sibling miniapps into the layer +# cache (the Dockerfile's `COPY` paths are relative to the miniapp dir, +# which is why `rootDirectory` points to the miniapp and `dockerfilePath` +# is the full monorepo path). Matches the pattern used by other miniapps +# with persistent volumes (ocb-stream-relay, hyperliquid-frontends). +# +# Healthcheck on /health hits the same :2114 the harness binds and the +# shared OCB Prometheus scrapes. We set PORT=2114 in the env block below +# so Railway's healthcheck probe and the harness agree on the port. + +[build] +builder = "DOCKERFILE" +dockerfilePath = "Dockerfile" +rootDirectory = "miniapps/hl-archive" + +[deploy] +healthcheckPath = "/health" +healthcheckTimeout = 30 +restartPolicyType = "ON_FAILURE" +restartPolicyMaxRetries = 5 + +# Persistent DuckDB file. Sized for ~50 MB cold start growing to ~1 GB +# over a year of daily aggregates (one row per builder per day, plus +# raw fills retention configured inside the service). +[[deploy.volumes]] +mountPath = "/data" +name = "hl-archive-duckdb" + +# ----------------------------------------------------------------- env -- +# All values below are declarations only. Real values are set in the +# Railway dashboard (or `railway variables set`). Secrets (API key, +# Upstash token) MUST be flagged "Sensitive" in the dashboard. + +[deploy.envs] + +# --- service auth --- +# Generate with: openssl rand -base64 32 +# REQUIRED. Mark as Sensitive in the Railway dashboard. +HL_ARCHIVE_API_KEY = "" + +# --- storage --- +# DuckDB file path. Lives on the /data volume so it survives redeploys. +HL_ARCHIVE_DB_PATH = "/data/history.duckdb" + +# Path to the builders.json baked into the image (see Dockerfile COPY). +HL_ARCHIVE_BUILDERS_FILE = "/app/data/builders.json" + +# --- Upstash snapshot push --- +# REQUIRED. Same Upstash project used by the OCB site. +UPSTASH_REDIS_REST_URL = "" + +# REQUIRED. Mark as Sensitive in the Railway dashboard. +UPSTASH_REDIS_REST_TOKEN = "" + +# Versioned key for the published snapshot. Bump suffix when the shape +# changes so the consumer can switch atomically. +HL_ARCHIVE_UPSTASH_KEY = "ocb:hl-archive:v1" + +# --- ingestion --- +# Earliest CDN day to backfill on first cold boot. The cron only fetches +# new days after this point on subsequent runs. +HL_ARCHIVE_BACKFILL_FROM = "2025-08-01" + +# Daily run anchor (UTC hour). The upstream CSV rollover is around +# 01:00 UTC; we run at 02:00 to give the bucket time to settle. +HL_ARCHIVE_CRON_HOUR = "2" + +# --- HTTP server --- +# Harness binds 0.0.0.0:2114 hardcoded for the shared OCB Prom scrape +# contract. Railway $PORT is forced to the same value below so the +# platform healthcheck and the harness agree. +HL_ARCHIVE_HTTP_ADDR = "0.0.0.0:2114" +PORT = "2114" + +# --- observability --- +LOG_LEVEL = "info" diff --git a/harnesses/hl-archive/script/agg.go b/harnesses/hl-archive/script/agg.go new file mode 100644 index 00000000..2df1e3c9 --- /dev/null +++ b/harnesses/hl-archive/script/agg.go @@ -0,0 +1,76 @@ +// agg.go — in-memory aggregation per (day, builder, coin). +// +// One DayAggregator is created per (builder, day) parse, populated by +// streaming the CSV through parse.go, then flushed atomically into +// DuckDB by store.go. Holding only aggregates (not raw rows) keeps +// memory bounded at O(unique_coins * unique_users) per builder/day. +package script + +// AggKey identifies a single output row in builder_daily_aggregates. +type AggKey struct { + Day string // YYYY-MM-DD + Builder string // lowercased 0x address + Asset string // coin symbol +} + +// AggValue holds the running sums for one AggKey. +type AggValue struct { + VolumeUSD float64 + FeesUSD float64 + FillCount int64 + uniqueUsers map[string]struct{} +} + +// UniqueUsers returns the cardinality without exposing the internal set. +func (a *AggValue) UniqueUsers() int64 { return int64(len(a.uniqueUsers)) } + +// DayAggregator buckets fills by (asset). The day + builder dimensions +// are fixed at construction time so we only key on coin. +type DayAggregator struct { + Day string + Builder string + buckets map[string]*AggValue +} + +func NewDayAggregator(day, builder string) *DayAggregator { + return &DayAggregator{Day: day, Builder: builder, buckets: map[string]*AggValue{}} +} + +// Add folds one fill into the running aggregates. +func (a *DayAggregator) Add(f Fill) { + b, ok := a.buckets[f.Coin] + if !ok { + b = &AggValue{uniqueUsers: map[string]struct{}{}} + a.buckets[f.Coin] = b + } + b.VolumeUSD += f.Px * f.Sz + b.FeesUSD += f.BuilderFee + b.FillCount++ + if f.User != "" { + b.uniqueUsers[f.User] = struct{}{} + } +} + +// Rows returns the final (key, value) pairs ready for upsert. +func (a *DayAggregator) Rows() []AggRow { + out := make([]AggRow, 0, len(a.buckets)) + for asset, v := range a.buckets { + out = append(out, AggRow{ + Key: AggKey{Day: a.Day, Builder: a.Builder, Asset: asset}, + VolumeUSD: v.VolumeUSD, + FeesUSD: v.FeesUSD, + FillCount: v.FillCount, + UniqueUsers: v.UniqueUsers(), + }) + } + return out +} + +// AggRow is the flat form used by store.go for batch inserts. +type AggRow struct { + Key AggKey + VolumeUSD float64 + FeesUSD float64 + FillCount int64 + UniqueUsers int64 +} diff --git a/harnesses/hl-archive/script/agg_test.go b/harnesses/hl-archive/script/agg_test.go new file mode 100644 index 00000000..d797432c --- /dev/null +++ b/harnesses/hl-archive/script/agg_test.go @@ -0,0 +1,38 @@ +package script + +import "testing" + +func TestDayAggregator_UniqueUsers(t *testing.T) { + a := NewDayAggregator("2026-05-31", "0xb") + a.Add(Fill{User: "u1", Coin: "BTC", Px: 100, Sz: 1, BuilderFee: 0.1}) + a.Add(Fill{User: "u1", Coin: "BTC", Px: 100, Sz: 1, BuilderFee: 0.1}) + a.Add(Fill{User: "u2", Coin: "BTC", Px: 100, Sz: 1, BuilderFee: 0.1}) + rows := a.Rows() + if len(rows) != 1 { + t.Fatalf("got %d rows", len(rows)) + } + if rows[0].FillCount != 3 { + t.Fatalf("fill_count: %d", rows[0].FillCount) + } + if rows[0].UniqueUsers != 2 { + t.Fatalf("unique_users: %d", rows[0].UniqueUsers) + } + if rows[0].VolumeUSD != 300 { + t.Fatalf("volume: %v", rows[0].VolumeUSD) + } +} + +func TestBuilderActiveOn(t *testing.T) { + b := Builder{ValidFrom: "2026-05-31"} + if !builderActiveOn(b, mustDay("2026-06-01")) { + t.Fatal("should be active on 06-01") + } + if builderActiveOn(b, mustDay("2026-05-30")) { + t.Fatal("should not be active on 05-30") + } + if !builderActiveOn(Builder{}, mustDay("2024-01-01")) { + t.Fatal("no valid_from means always active") + } +} + +func mustDay(s string) (t timeStub) { return parseDay(s) } diff --git a/harnesses/hl-archive/script/cli.go b/harnesses/hl-archive/script/cli.go new file mode 100644 index 00000000..4c04fd5d --- /dev/null +++ b/harnesses/hl-archive/script/cli.go @@ -0,0 +1,414 @@ +// cli.go — subcommand dispatcher + per-day worker pool. +// +// Each subcommand is a tiny function that wires env -> store -> +// ProcessDay/ProcessRange. ProcessDay holds the worker pool that +// drives parse.go concurrently across builders for one day. +package script + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "os/signal" + "strings" + "sync" + "syscall" + "time" +) + +// Builder is one entry in data/builders.json. A few builders register +// multiple addresses; AllAddresses returns the deduped set. +type Builder struct { + Slug string `json:"slug"` + Name string `json:"name"` + Address string `json:"address"` + ValidFrom string `json:"valid_from"` + Addresses []string `json:"addresses,omitempty"` + Notes string `json:"notes,omitempty"` +} + +func (b Builder) AllAddresses() []string { + set := map[string]struct{}{} + if b.Address != "" { + set[strings.ToLower(b.Address)] = struct{}{} + } + for _, a := range b.Addresses { + if a != "" { + set[strings.ToLower(a)] = struct{}{} + } + } + out := make([]string, 0, len(set)) + for a := range set { + out = append(out, a) + } + return out +} + +// LoadBuilders reads the JSON file at path. +func LoadBuilders(path string) ([]Builder, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open builders file %s: %w", path, err) + } + defer f.Close() + var out []Builder + if err := json.NewDecoder(f).Decode(&out); err != nil { + return nil, fmt.Errorf("decode builders file: %w", err) + } + return out, nil +} + +// Run is the package entrypoint called from cmd/hl-archive/main.go. +func Run(args []string) error { + initLogger() + if len(args) == 0 { + printUsage() + return errors.New("missing subcommand") + } + cmd, rest := args[0], args[1:] + switch cmd { + case "backfill": + return cmdBackfill(rest) + case "daily": + return cmdDaily(rest) + case "rebuild": + return cmdRebuild(rest) + case "serve": + return cmdServe(rest) + case "query": + return cmdQuery(rest) + case "-h", "--help", "help": + printUsage() + return nil + default: + printUsage() + return fmt.Errorf("unknown subcommand: %s", cmd) + } +} + +func printUsage() { + fmt.Fprintf(os.Stderr, `hl-archive — Hyperliquid builder fee/volume archive + +Usage: + hl-archive backfill --from YYYY-MM-DD --to YYYY-MM-DD [--workers N] + hl-archive daily + hl-archive rebuild --confirm + hl-archive serve + hl-archive query --builder 0x... --days N +`) +} + +func envDefault(key, def string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return def +} + +func openStoreFromEnv() (*Store, error) { + path := envDefault("HL_ARCHIVE_DB_PATH", "/data/history.duckdb") + if err := os.MkdirAll(dirOf(path), 0o755); err != nil { + return nil, fmt.Errorf("mkdir db dir: %w", err) + } + return OpenStore(path) +} + +func dirOf(p string) string { + if i := strings.LastIndex(p, "/"); i > 0 { + return p[:i] + } + return "." +} + +func loadBuildersFromEnv() ([]Builder, error) { + path := envDefault("HL_ARCHIVE_BUILDERS_FILE", "./data/builders.json") + return LoadBuilders(path) +} + +func cmdBackfill(args []string) error { + fs := flag.NewFlagSet("backfill", flag.ExitOnError) + from := fs.String("from", "", "YYYY-MM-DD (inclusive)") + to := fs.String("to", "", "YYYY-MM-DD (inclusive)") + workers := fs.Int("workers", 16, "concurrent builders per day") + _ = fs.Parse(args) + + if *from == "" || *to == "" { + return errors.New("--from and --to are required") + } + fromT, err := time.Parse("2006-01-02", *from) + if err != nil { + return fmt.Errorf("--from: %w", err) + } + toT, err := time.Parse("2006-01-02", *to) + if err != nil { + return fmt.Errorf("--to: %w", err) + } + if toT.Before(fromT) { + return errors.New("--to is before --from") + } + // Today's CDN file is still being written; ingesting it would store + // a partial day that the cron loop later overwrites. Force callers + // to use `daily` (which targets yesterday) for the most recent day. + today := time.Now().UTC().Truncate(24 * time.Hour) + if !toT.Before(today) { + return errors.New("--to must be < today UTC (today's CDN file is still growing); use 'daily' instead") + } + + store, err := openStoreFromEnv() + if err != nil { + return err + } + defer store.Close() + builders, err := loadBuildersFromEnv() + if err != nil { + return err + } + + ctx, cancel := signalCtx() + defer cancel() + + totalRows := int64(0) + for d := fromT; !d.After(toT); d = d.AddDate(0, 0, 1) { + if processed, err := store.IsDayProcessed(ctx, d); err == nil && processed { + Log.Info("skip day (already processed)", "day", d.Format("2006-01-02")) + continue + } + res, err := ProcessDay(ctx, store, builders, d, "backfill", *workers) + if err != nil { + Log.Error("backfill day failed", "day", d.Format("2006-01-02"), "err", err) + continue + } + totalRows += res.Rows + Log.Info("backfill day ok", + "day", d.Format("2006-01-02"), + "rows", res.Rows, "builders", res.Builders, "duration_ms", res.Duration.Milliseconds()) + } + Log.Info("backfill done", "rows_total", totalRows) + refreshDBGauges(ctx, store) + return nil +} + +func cmdDaily(_ []string) error { + store, err := openStoreFromEnv() + if err != nil { + return err + } + defer store.Close() + builders, err := loadBuildersFromEnv() + if err != nil { + return err + } + ctx, cancel := signalCtx() + defer cancel() + day := time.Now().UTC().AddDate(0, 0, -1) + res, err := ProcessDay(ctx, store, builders, day, "daily", 16) + if err != nil { + return err + } + Log.Info("daily ok", "day", day.Format("2006-01-02"), "rows", res.Rows, "builders", res.Builders) + MetricLastRun.Set(float64(time.Now().Unix())) + refreshDBGauges(ctx, store) + + payload, err := buildPayload(ctx, store, builders, 0, "") + if err != nil { + return err + } + return PushUpstash(ctx, payload) +} + +func cmdRebuild(args []string) error { + fs := flag.NewFlagSet("rebuild", flag.ExitOnError) + confirm := fs.Bool("confirm", false, "must be set to actually wipe the DB") + _ = fs.Parse(args) + if !*confirm { + return errors.New("refusing to rebuild without --confirm") + } + store, err := openStoreFromEnv() + if err != nil { + return err + } + defer store.Close() + if err := store.Wipe(context.Background()); err != nil { + return err + } + from := envDefault("HL_ARCHIVE_BACKFILL_FROM", "2025-07-10") + to := time.Now().UTC().AddDate(0, 0, -1).Format("2006-01-02") + return cmdBackfill([]string{"--from", from, "--to", to}) +} + +func cmdServe(_ []string) error { + apiKey := strings.TrimSpace(os.Getenv("HL_ARCHIVE_API_KEY")) + if apiKey == "" { + return errors.New("HL_ARCHIVE_API_KEY is required for serve mode") + } + addr := envDefault("HL_ARCHIVE_HTTP_ADDR", "0.0.0.0:2114") + + store, err := openStoreFromEnv() + if err != nil { + return err + } + defer store.Close() + builders, err := loadBuildersFromEnv() + if err != nil { + return err + } + + ctx, cancel := signalCtx() + defer cancel() + refreshDBGauges(ctx, store) + return Serve(ctx, store, builders, apiKey, addr) +} + +func cmdQuery(args []string) error { + fs := flag.NewFlagSet("query", flag.ExitOnError) + builder := fs.String("builder", "", "0x address") + days := fs.Int("days", 30, "number of days back") + _ = fs.Parse(args) + if *builder == "" { + return errors.New("--builder is required") + } + store, err := openStoreFromEnv() + if err != nil { + return err + } + defer store.Close() + rows, err := store.QueryBuilderTimeseries(context.Background(), strings.ToLower(*builder), *days) + if err != nil { + return err + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(rows) +} + +func signalCtx() (context.Context, context.CancelFunc) { + ctx, cancel := context.WithCancel(context.Background()) + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt, syscall.SIGTERM) + go func() { + <-sig + Log.Info("signal received, cancelling") + cancel() + }() + return ctx, cancel +} + +// DayResult summarises one ProcessDay call. +type DayResult struct { + Rows int64 + Builders int + Duration time.Duration +} + +// ProcessDay parses every builder for `day` concurrently and commits +// the aggregated rows in a single DuckDB transaction. Returns even +// when individual builders 404'd (those just contribute zero rows). +func ProcessDay(ctx context.Context, store *Store, builders []Builder, day time.Time, source string, workers int) (DayResult, error) { + if workers <= 0 { + workers = 16 + } + start := time.Now() + dayStr := day.UTC().Format("2006-01-02") + + type job struct { + builderAddr string + builderName string + } + jobs := make(chan job, workers*2) + + var ( + mu sync.Mutex + rowsByAddr = map[string][]AggRow{} + wg sync.WaitGroup + dbPath = envDefault("HL_ARCHIVE_DB_PATH", "/data/history.duckdb") + ) + + for i := 0; i < workers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for j := range jobs { + select { + case <-ctx.Done(): + return + default: + } + agg := NewDayAggregator(dayStr, strings.ToLower(j.builderAddr)) + n, err := FetchDay(ctx, j.builderAddr, day, func(f Fill) error { + agg.Add(f) + return nil + }) + switch { + case errors.Is(err, ErrNotFound): + MetricFilesProcessed.WithLabelValues("cdn", "notfound").Inc() + mu.Lock() + rowsByAddr[strings.ToLower(j.builderAddr)] = nil + mu.Unlock() + continue + case err != nil: + Log.Error("fetch day failed", + "builder", j.builderAddr, "day", dayStr, "err", err) + if strings.Contains(err.Error(), "lz4") { + DeadLetter(dbPath, j.builderAddr, dayStr, err.Error()) + } + MetricFilesProcessed.WithLabelValues("cdn", "error").Inc() + continue + } + MetricFilesProcessed.WithLabelValues("cdn", "ok").Inc() + rows := agg.Rows() + mu.Lock() + rowsByAddr[strings.ToLower(j.builderAddr)] = rows + mu.Unlock() + Log.Debug("builder done", "builder", j.builderAddr, "day", dayStr, "fills", n, "rows", len(rows)) + } + }() + } + + seen := map[string]bool{} + for _, b := range builders { + if !builderActiveOn(b, day) { + continue + } + for _, addr := range b.AllAddresses() { + if seen[addr] { + continue + } + seen[addr] = true + jobs <- job{builderAddr: addr, builderName: b.Name} + } + } + close(jobs) + wg.Wait() + + if err := ctx.Err(); err != nil { + return DayResult{}, err + } + + if err := store.CommitDay(ctx, day, source, rowsByAddr, time.Since(start)); err != nil { + return DayResult{}, err + } + + var totalRows int64 + for _, rows := range rowsByAddr { + totalRows += int64(len(rows)) + } + return DayResult{ + Rows: totalRows, + Builders: len(rowsByAddr), + Duration: time.Since(start), + }, nil +} + +func builderActiveOn(b Builder, day time.Time) bool { + if b.ValidFrom == "" { + return true + } + vf, err := time.Parse("2006-01-02", b.ValidFrom) + if err != nil { + return true + } + return !day.Before(vf) +} diff --git a/harnesses/hl-archive/script/integration_test.go b/harnesses/hl-archive/script/integration_test.go new file mode 100644 index 00000000..cf623ea7 --- /dev/null +++ b/harnesses/hl-archive/script/integration_test.go @@ -0,0 +1,62 @@ +package script + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" +) + +// Full end-to-end: mocked CDN -> ProcessDay -> CommitDay -> Stats. +func TestProcessDay_EndToEnd(t *testing.T) { + body := lz4Encode(t, []byte(sampleCSV)) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(body) + })) + defer srv.Close() + + prev := HTTPClient + defer func() { HTTPClient = prev }() + HTTPClient = srv.Client() + HTTPClient.Transport = &redirectingTransport{target: srv.URL} + + dir := t.TempDir() + dbPath := filepath.Join(dir, "test.duckdb") + os.Setenv("HL_ARCHIVE_DB_PATH", dbPath) + + store, err := OpenStore(dbPath) + if err != nil { + t.Fatalf("open store: %v", err) + } + defer store.Close() + + builders := []Builder{{ + Slug: "test", Name: "Test", + Address: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }} + day := time.Date(2026, 5, 31, 0, 0, 0, 0, time.UTC) + res, err := ProcessDay(context.Background(), store, builders, day, "test", 2) + if err != nil { + t.Fatalf("process day: %v", err) + } + if res.Rows != 2 { // BTC + ETH buckets + t.Fatalf("rows: %d", res.Rows) + } + processed, err := store.IsDayProcessed(context.Background(), day) + if err != nil || !processed { + t.Fatalf("not marked processed: err=%v processed=%v", err, processed) + } + st, err := store.Stats(context.Background()) + if err != nil { + t.Fatalf("stats: %v", err) + } + if st.BuildersCount != 1 { + t.Errorf("builders count: %d", st.BuildersCount) + } + if st.DaysCount != 1 { + t.Errorf("days count: %d", st.DaysCount) + } +} diff --git a/harnesses/hl-archive/script/logger.go b/harnesses/hl-archive/script/logger.go new file mode 100644 index 00000000..f04f461a --- /dev/null +++ b/harnesses/hl-archive/script/logger.go @@ -0,0 +1,34 @@ +// logger.go — slog JSON logger initialisation. +// +// Single package-level *slog.Logger configured from LOG_LEVEL. +// All other files use Log.* directly so logs stay structured and +// trivially shippable to BetterStack/Loki without per-call setup. +package script + +import ( + "log/slog" + "os" + "strings" + "sync" +) + +var ( + Log *slog.Logger + loggerOnce sync.Once +) + +func initLogger() { + loggerOnce.Do(func() { + level := slog.LevelInfo + switch strings.ToLower(strings.TrimSpace(os.Getenv("LOG_LEVEL"))) { + case "debug": + level = slog.LevelDebug + case "warn", "warning": + level = slog.LevelWarn + case "error": + level = slog.LevelError + } + Log = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level})) + slog.SetDefault(Log) + }) +} diff --git a/harnesses/hl-archive/script/metrics.go b/harnesses/hl-archive/script/metrics.go new file mode 100644 index 00000000..89787d88 --- /dev/null +++ b/harnesses/hl-archive/script/metrics.go @@ -0,0 +1,60 @@ +// metrics.go — Prometheus metric registration. +// +// All metrics are package-level vars so any handler can update them +// without passing a registry around. Names follow the +// `hl_archive_*` namespace agreed in the spec; do not rename without +// updating the dashboards. +package script + +import ( + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +var ( + MetricLastRun = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "hl_archive_last_run_unix_seconds", + Help: "Unix timestamp of the last completed daily run (any result).", + }) + + MetricFilesProcessed = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "hl_archive_files_processed_total", + Help: "Number of CDN CSV files processed, by source and result.", + }, []string{"source", "result"}) + + MetricDBSize = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "hl_archive_db_size_bytes", + Help: "Size of the DuckDB file on disk.", + }) + + MetricBuildersCount = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "hl_archive_builders_count", + Help: "Number of distinct builders with at least one aggregate row.", + }) + + MetricDaysCount = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "hl_archive_days_count", + Help: "Number of distinct days in builder_daily_aggregates.", + }) + + MetricLagHours = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "hl_archive_lag_hours", + Help: "Hours between now and the most recent processed day.", + }) + + MetricUpstashPushDur = promauto.NewHistogram(prometheus.HistogramOpts{ + Name: "hl_archive_upstash_push_duration_seconds", + Help: "Duration of Upstash REST push calls.", + Buckets: prometheus.DefBuckets, + }) + + MetricCronRuns = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "hl_archive_cron_runs_total", + Help: "Internal daily-cron firings, by result (ok|err).", + }, []string{"result"}) + + MetricHTTPRequests = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "hl_archive_http_requests_total", + Help: "HTTP requests served by the API.", + }, []string{"path", "code"}) +) diff --git a/harnesses/hl-archive/script/parse.go b/harnesses/hl-archive/script/parse.go new file mode 100644 index 00000000..e27581d6 --- /dev/null +++ b/harnesses/hl-archive/script/parse.go @@ -0,0 +1,229 @@ +// parse.go — CDN fetch + LZ4 decompress + streaming CSV decode. +// +// The HL CDN serves one .csv.lz4 file per (builder, day). This file +// owns the network I/O and the row decoder; aggregation lives in +// agg.go so the parser stays focused and unit-testable against a +// httptest server with a tiny LZ4-encoded CSV fixture. +// +// Networking choices: +// - default to net/http with a 60s per-request timeout +// - exponential backoff on 5xx (max 5 attempts: 1s,2s,4s,8s,16s,32s) +// - 404 returned as ErrNotFound so the caller records a zero-row day +// - corrupt LZ4 -> dead-letter the day to ${DB}.failures.jsonl +// +// Streaming: lz4.Reader wraps the response Body, then csv.Reader +// streams rows so we never hold a full day's fills in RAM. +package script + +import ( + "context" + "encoding/csv" + "encoding/json" + "errors" + "fmt" + "io" + "math/rand" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/pierrec/lz4/v4" +) + +const cdnURLTemplate = "https://stats-data.hyperliquid.xyz/Mainnet/builder_fills/%s/%s.csv.lz4" + +// ErrNotFound = CDN 404 (builder had no fills that day). +var ErrNotFound = errors.New("cdn: not found") + +// Fill mirrors one row of the CSV after type coercion. Only the +// columns we actually aggregate on are typed; the rest stay raw to +// avoid wasted decode cost. +type Fill struct { + Time int64 + User string + Coin string + Px float64 + Sz float64 + BuilderFee float64 +} + +// HTTPClient is overridable from tests. +var HTTPClient = &http.Client{Timeout: 60 * time.Second} + +// FetchDay downloads + decompresses the file for (builder, day) and +// streams rows into the supplied callback. Returns the number of rows +// emitted or ErrNotFound if the CDN replied 404. +func FetchDay(ctx context.Context, builder string, day time.Time, fn func(Fill) error) (int, error) { + url := fmt.Sprintf(cdnURLTemplate, strings.ToLower(builder), day.UTC().Format("20060102")) + body, err := getWithBackoff(ctx, url) + if err != nil { + return 0, err + } + defer body.Close() + + zr := lz4.NewReader(body) + cr := csv.NewReader(zr) + cr.ReuseRecord = true + cr.FieldsPerRecord = -1 + + header, err := cr.Read() + if err != nil { + if errors.Is(err, io.EOF) { + return 0, nil + } + return 0, fmt.Errorf("read header: %w", err) + } + idx, err := indexColumns(header) + if err != nil { + return 0, err + } + + count := 0 + for { + rec, err := cr.Read() + if err == io.EOF { + break + } + if err != nil { + return count, fmt.Errorf("read row %d: %w", count, err) + } + f, perr := parseRow(rec, idx) + if perr != nil { + Log.Debug("skip bad row", "builder", builder, "day", day.Format("2006-01-02"), "err", perr) + continue + } + if err := fn(f); err != nil { + return count, err + } + count++ + } + return count, nil +} + +type colIndex struct { + time, user, coin, px, sz, builderFee int +} + +func indexColumns(header []string) (colIndex, error) { + idx := colIndex{time: -1, user: -1, coin: -1, px: -1, sz: -1, builderFee: -1} + for i, h := range header { + switch strings.TrimSpace(strings.ToLower(h)) { + case "time": + idx.time = i + case "user": + idx.user = i + case "coin": + idx.coin = i + case "px": + idx.px = i + case "sz": + idx.sz = i + case "builder_fee": + idx.builderFee = i + } + } + if idx.user < 0 || idx.coin < 0 || idx.px < 0 || idx.sz < 0 || idx.builderFee < 0 { + return idx, fmt.Errorf("csv header missing required columns: got %v", header) + } + return idx, nil +} + +func parseRow(rec []string, idx colIndex) (Fill, error) { + if len(rec) <= idx.builderFee { + return Fill{}, fmt.Errorf("short row (%d cols)", len(rec)) + } + px, err := strconv.ParseFloat(strings.TrimSpace(rec[idx.px]), 64) + if err != nil { + return Fill{}, fmt.Errorf("px: %w", err) + } + sz, err := strconv.ParseFloat(strings.TrimSpace(rec[idx.sz]), 64) + if err != nil { + return Fill{}, fmt.Errorf("sz: %w", err) + } + fee, err := strconv.ParseFloat(strings.TrimSpace(rec[idx.builderFee]), 64) + if err != nil { + fee = 0 // tolerate blank fee columns + } + var ts int64 + if idx.time >= 0 { + ts, _ = strconv.ParseInt(strings.TrimSpace(rec[idx.time]), 10, 64) + } + return Fill{ + Time: ts, + User: strings.TrimSpace(rec[idx.user]), + Coin: strings.TrimSpace(rec[idx.coin]), + Px: px, + Sz: sz, + BuilderFee: fee, + }, nil +} + +func getWithBackoff(ctx context.Context, url string) (io.ReadCloser, error) { + const maxAttempts = 6 // 1+5 retries = 1s,2s,4s,8s,16s,32s + var lastErr error + for attempt := 0; attempt < maxAttempts; attempt++ { + if attempt > 0 { + delay := time.Duration(1<<(attempt-1)) * time.Second + jitter := time.Duration(rand.Int63n(int64(delay) / 4)) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(delay + jitter): + } + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "hl-archive/1.0 (+ocb)") + resp, err := HTTPClient.Do(req) + if err != nil { + lastErr = err + continue + } + switch { + case resp.StatusCode == http.StatusNotFound, + resp.StatusCode == http.StatusForbidden: + // 404 = builder/day combination not in the CDN's index. + // 403 = same in practice for this bucket: CloudFront serves + // 403 (not 404) for objects that never existed under that + // prefix, e.g. a builder that had zero fills that day or + // did not yet exist on chain. Treat as "no data" — record + // a zero-row processed day, do not retry, do not error. + resp.Body.Close() + return nil, ErrNotFound + case resp.StatusCode >= 500: + resp.Body.Close() + lastErr = fmt.Errorf("cdn %d", resp.StatusCode) + continue + case resp.StatusCode >= 400: + resp.Body.Close() + return nil, fmt.Errorf("cdn %d", resp.StatusCode) + default: + return resp.Body, nil + } + } + return nil, fmt.Errorf("cdn unavailable after retries: %w", lastErr) +} + +// DeadLetter appends a JSON line describing an unrecoverable parse +// failure (e.g. corrupt LZ4). The caller is expected to skip the day +// and continue; an operator can grep the file to backfill manually. +func DeadLetter(dbPath, builder, day, reason string) { + path := dbPath + ".failures.jsonl" + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + Log.Error("dead-letter open failed", "path", path, "err", err) + return + } + defer f.Close() + enc := json.NewEncoder(f) + _ = enc.Encode(map[string]string{ + "ts": time.Now().UTC().Format(time.RFC3339), + "builder": builder, + "day": day, + "reason": reason, + }) +} diff --git a/harnesses/hl-archive/script/parse_test.go b/harnesses/hl-archive/script/parse_test.go new file mode 100644 index 00000000..18491dc4 --- /dev/null +++ b/harnesses/hl-archive/script/parse_test.go @@ -0,0 +1,117 @@ +package script + +import ( + "bytes" + "context" + "errors" + "net/http" + "net/http/httptest" + "sort" + "testing" + "time" + + "github.com/pierrec/lz4/v4" +) + +func init() { initLogger() } + +const sampleCSV = `time,user,coin,side,px,sz,crossed,special_trade_type,tif,is_trigger,counterparty,closed_pnl,twap_id,builder_fee +1748736000000,0xuserA,BTC,B,60000,0.1,1,,Gtc,false,0xcp,0,,0.5 +1748736001000,0xuserB,BTC,S,60010,0.2,1,,Gtc,false,0xcp,0,,1.2 +1748736002000,0xuserA,ETH,B,3000,1,1,,Gtc,false,0xcp,0,,0.3 +` + +func lz4Encode(t *testing.T, raw []byte) []byte { + t.Helper() + var buf bytes.Buffer + w := lz4.NewWriter(&buf) + if _, err := w.Write(raw); err != nil { + t.Fatalf("lz4 write: %v", err) + } + if err := w.Close(); err != nil { + t.Fatalf("lz4 close: %v", err) + } + return buf.Bytes() +} + +func TestFetchDay_ParsesAndAggregates(t *testing.T) { + body := lz4Encode(t, []byte(sampleCSV)) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(body) + })) + defer srv.Close() + + prev := HTTPClient + defer func() { HTTPClient = prev }() + HTTPClient = srv.Client() + // Redirect the CDN to httptest by overriding URL in test via custom RoundTripper. + HTTPClient.Transport = &redirectingTransport{target: srv.URL} + + agg := NewDayAggregator("2026-05-31", "0xb84168cf3be63c6b8dad05ff5d755e97432ff80b") + n, err := FetchDay(context.Background(), "0xb84168cf3be63c6b8dad05ff5d755e97432ff80b", time.Date(2026, 5, 31, 0, 0, 0, 0, time.UTC), func(f Fill) error { + agg.Add(f) + return nil + }) + if err != nil { + t.Fatalf("fetch: %v", err) + } + if n != 3 { + t.Fatalf("expected 3 rows, got %d", n) + } + rows := agg.Rows() + sort.Slice(rows, func(i, j int) bool { return rows[i].Key.Asset < rows[j].Key.Asset }) + if len(rows) != 2 { + t.Fatalf("expected 2 asset buckets, got %d", len(rows)) + } + // BTC: 60000*0.1 + 60010*0.2 = 6000 + 12002 = 18002 + // ETH: 3000*1 = 3000 + if rows[0].Key.Asset != "BTC" || rows[0].VolumeUSD != 18002 { + t.Errorf("BTC volume: got %+v", rows[0]) + } + if rows[0].FeesUSD != 1.7 { + t.Errorf("BTC fees: got %v want 1.7", rows[0].FeesUSD) + } + if rows[0].UniqueUsers != 2 { + t.Errorf("BTC unique users: got %d", rows[0].UniqueUsers) + } + if rows[1].Key.Asset != "ETH" || rows[1].VolumeUSD != 3000 || rows[1].FillCount != 1 { + t.Errorf("ETH row: got %+v", rows[1]) + } +} + +func TestFetchDay_NotFound(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.NotFound(w, nil) + })) + defer srv.Close() + + prev := HTTPClient + defer func() { HTTPClient = prev }() + HTTPClient = srv.Client() + HTTPClient.Transport = &redirectingTransport{target: srv.URL} + + _, err := FetchDay(context.Background(), "0xdead", time.Now().UTC(), func(Fill) error { return nil }) + if !errors.Is(err, ErrNotFound) { + t.Fatalf("expected ErrNotFound, got %v", err) + } +} + +// redirectingTransport rewrites every outbound request to the test server. +type redirectingTransport struct{ target string } + +func (rt *redirectingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + u := req.URL + u.Scheme = "http" + u.Host = trimScheme(rt.target) + req.Host = u.Host + return http.DefaultTransport.RoundTrip(req) +} + +func trimScheme(s string) string { + for _, p := range []string{"http://", "https://"} { + if len(s) > len(p) && s[:len(p)] == p { + return s[len(p):] + } + } + return s +} diff --git a/harnesses/hl-archive/script/server.go b/harnesses/hl-archive/script/server.go new file mode 100644 index 00000000..947f8029 --- /dev/null +++ b/harnesses/hl-archive/script/server.go @@ -0,0 +1,377 @@ +// server.go — HTTP API + internal cron loop. +// +// Three public endpoints: +// GET /health — open, JSON snapshot of store stats +// GET /metrics — open, Prometheus text format +// GET /v1/aggregates?window= — auth required (X-API-Key), JSON +// +// Auth: any non-open route requires X-API-Key matching the env +// HL_ARCHIVE_API_KEY. Empty env aborts serve mode at startup. +// +// Cron loop: a single goroutine sleeps until HL_ARCHIVE_CRON_HOUR +// UTC every day, then runs DailyJob. +package script + +import ( + "context" + "crypto/subtle" + "encoding/json" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +const serviceVersion = "1.0.0" + +// Serve blocks until ctx is cancelled. +func Serve(ctx context.Context, store *Store, builders []Builder, apiKey, addr string) error { + mux := http.NewServeMux() + mux.HandleFunc("/health", instrument("/health", handleHealth(store))) + mux.Handle("/metrics", instrument("/metrics", promhttp.Handler().ServeHTTP)) + mux.HandleFunc("/v1/aggregates", instrument("/v1/aggregates", authGate(apiKey, handleAggregates(store, builders)))) + + srv := &http.Server{ + Addr: addr, + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + WriteTimeout: 30 * time.Second, + } + + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = srv.Shutdown(shutdownCtx) + }() + + // Catch up any days missed while the pod was down. Sequential on + // purpose (one ProcessDay at a time) so we don't hammer the CDN at + // boot. Runs before the cron loop so the regular tick starts from + // a clean state. + go func() { + runCatchup(ctx, store, builders) + cronLoop(ctx, store, builders) + }() + + Log.Info("http server listening", "addr", addr) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + return err + } + return nil +} + +func instrument(path string, h http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + rec := &statusRecorder{ResponseWriter: w, code: 200} + h(rec, r) + MetricHTTPRequests.WithLabelValues(path, strconv.Itoa(rec.code)).Inc() + } +} + +type statusRecorder struct { + http.ResponseWriter + code int +} + +func (s *statusRecorder) WriteHeader(c int) { s.code = c; s.ResponseWriter.WriteHeader(c) } + +func authGate(apiKey string, h http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + got := r.Header.Get("X-API-Key") + if apiKey == "" || got == "" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + if subtle.ConstantTimeCompare([]byte(got), []byte(apiKey)) != 1 { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + h(w, r) + } +} + +func handleHealth(store *Store) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + s, err := store.Stats(r.Context()) + status := "ok" + if err != nil || s.LagHours > 48 { + status = "degraded" + } + writeJSON(w, http.StatusOK, map[string]any{ + "status": status, + "last_processed_day": s.LastProcessedDay, + "lag_hours": round2(s.LagHours), + "db_size_bytes": s.DBSizeBytes, + "builders_count": s.BuildersCount, + "days_count": s.DaysCount, + "version": serviceVersion, + }) + } +} + +func handleAggregates(store *Store, builders []Builder) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + window := strings.TrimSpace(r.URL.Query().Get("window")) + days, ok := windowToDays(window) + if window != "" && !ok { + http.Error(w, "invalid window", http.StatusBadRequest) + return + } + + payload, err := buildPayload(r.Context(), store, builders, days, window) + if err != nil { + Log.Error("aggregates query failed", "err", err) + http.Error(w, "internal", http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, payload) + } +} + +// buildPayload constructs the Upstash-shaped JSON. When a single window +// is requested we still emit the full `windows` map (cheap) but limit +// the timeseries to that window's day count. +func buildPayload(ctx context.Context, store *Store, builders []Builder, days int, _ string) (UpstashPayload, error) { + windows := standardWindows() + wm, err := store.QueryWindowsForAllBuilders(ctx, windows) + if err != nil { + return UpstashPayload{}, err + } + tsDays := days + if tsDays <= 0 || tsDays > 400 { + tsDays = 400 + } + ts, err := store.QueryDailyTimeseriesAllBuilders(ctx, tsDays) + if err != nil { + return UpstashPayload{}, err + } + + out := UpstashPayload{ + UpdatedAt: time.Now().UTC().Format(time.RFC3339), + Builders: map[string]UpstashBuilder{}, + } + nameByAddr := map[string]string{} + for _, b := range builders { + for _, a := range b.AllAddresses() { + nameByAddr[strings.ToLower(a)] = b.Name + } + } + for addr, wins := range wm { + out.Builders[addr] = UpstashBuilder{ + Name: nameByAddr[strings.ToLower(addr)], + Windows: wins, + TimeseriesDaily: ts[addr], + } + } + return out, nil +} + +func standardWindows() map[string]int { + return map[string]int{ + "24h": 1, "7d": 7, "30d": 30, "90d": 90, + "180d": 180, "1y": 365, "all": 0, + } +} + +func windowToDays(w string) (int, bool) { + switch w { + case "": + return 0, true + case "24h": + return 1, true + case "7d": + return 7, true + case "30d": + return 30, true + case "90d": + return 90, true + case "180d": + return 180, true + case "1y": + return 365, true + case "all": + return 0, true + } + return 0, false +} + +func writeJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(v) +} + +func round2(f float64) float64 { + return float64(int64(f*100)) / 100 +} + +// runCatchup ingests every UTC day from (last_processed + 1) up to +// (today - 1) one at a time. Today's CDN file is still growing so it +// is intentionally excluded; the cron loop will pick it up tomorrow. +// +// Before the forward pass, if HL_ARCHIVE_BACKFILL_FROM is older than the +// oldest day in the DB we first fill the gap on the LEFT of the existing +// range. This lets ops bump BACKFILL_FROM and have the next boot auto-fetch +// the new history without a manual `rebuild --confirm`. +func runCatchup(ctx context.Context, store *Store, builders []Builder) { + stats, err := store.Stats(ctx) + if err != nil { + Log.Error("cron catchup stats failed", "err", err) + return + } + + from := envDefault("HL_ARCHIVE_BACKFILL_FROM", "2025-07-10") + fromT, fromErr := time.Parse("2006-01-02", from) + if fromErr != nil { + Log.Error("cron catchup parse backfill_from failed", "value", from, "err", fromErr) + } + + // Left-side gap-fill: BACKFILL_FROM < oldest_processed_day. Runs only + // when the DB already has data and the env var was bumped backward. + if fromErr == nil && stats.LastProcessedDay != "" { + oldest, err := store.OldestProcessedDay(ctx) + if err != nil { + Log.Error("cron catchup oldest day failed", "err", err) + } else if oldest != "" { + oldestT, err := time.Parse("2006-01-02", oldest) + if err != nil { + Log.Error("cron catchup parse oldest day failed", "day", oldest, "err", err) + } else if fromT.Before(oldestT) { + Log.Info("cron catchup gap-fill", "from", from, "to", oldestT.AddDate(0, 0, -1).Format("2006-01-02")) + for d := fromT; d.Before(oldestT); d = d.AddDate(0, 0, 1) { + if err := ctx.Err(); err != nil { + return + } + res, err := ProcessDay(ctx, store, builders, d, "gap-fill", 16) + if err != nil { + Log.Error("cron catchup gap-fill failed", "day", d.Format("2006-01-02"), "err", err) + continue + } + Log.Info("cron catchup gap-fill day", "day", d.Format("2006-01-02"), "rows", res.Rows) + } + } + } + } + + var startDay time.Time + if stats.LastProcessedDay == "" { + // Cold DB: seed from HL_ARCHIVE_BACKFILL_FROM. Goroutine off the + // serve path so /health stays responsive while the multi-hour + // initial backfill walks forward day-by-day in the background. + if fromErr != nil { + return + } + startDay = fromT + Log.Info("cron catchup cold start", "from", from) + } else { + last, err := time.Parse("2006-01-02", stats.LastProcessedDay) + if err != nil { + Log.Error("cron catchup parse last day failed", "day", stats.LastProcessedDay, "err", err) + return + } + startDay = last.AddDate(0, 0, 1) + } + today := time.Now().UTC().Truncate(24 * time.Hour) + for d := startDay; d.Before(today); d = d.AddDate(0, 0, 1) { + if err := ctx.Err(); err != nil { + return + } + res, err := ProcessDay(ctx, store, builders, d, "catchup", 16) + if err != nil { + Log.Error("cron catchup failed", "day", d.Format("2006-01-02"), "err", err) + continue + } + Log.Info("cron catchup", "day", d.Format("2006-01-02"), "rows", res.Rows) + } + refreshDBGauges(ctx, store) + // Push the rebuilt snapshot to Upstash after catchup so OCB sees the + // data without waiting until the next cron tick at HL_ARCHIVE_CRON_HOUR. + // Cold-boot from an empty DB would otherwise hold the published snapshot + // stale for up to 24 hours. + pushCtx, cancel := context.WithTimeout(ctx, 60*time.Second) + defer cancel() + payload, err := buildPayload(pushCtx, store, builders, 0, "") + if err != nil { + Log.Error("post-catchup payload build failed", "err", err) + return + } + if err := PushUpstash(pushCtx, payload); err != nil { + Log.Error("post-catchup upstash push failed", "err", err) + } +} + +// cronLoop fires DailyJob once a day at HL_ARCHIVE_CRON_HOUR UTC. +func cronLoop(ctx context.Context, store *Store, builders []Builder) { + hour := 2 + if v := os.Getenv("HL_ARCHIVE_CRON_HOUR"); v != "" { + if h, err := strconv.Atoi(v); err == nil && h >= 0 && h < 24 { + hour = h + } + } + for { + next := nextCronAt(time.Now().UTC(), hour) + Log.Info("cron next fire", "at", next.Format(time.RFC3339)) + select { + case <-ctx.Done(): + return + case <-time.After(time.Until(next)): + } + runCron(ctx, store, builders) + } +} + +func nextCronAt(now time.Time, hourUTC int) time.Time { + candidate := time.Date(now.Year(), now.Month(), now.Day(), hourUTC, 0, 0, 0, time.UTC) + if !candidate.After(now) { + candidate = candidate.Add(24 * time.Hour) + } + return candidate +} + +func runCron(ctx context.Context, store *Store, builders []Builder) { + day := time.Now().UTC().AddDate(0, 0, -1) + dayStr := day.Format("2006-01-02") + // Skip if the catchup loop or a prior cron tick already ingested this day, + // so a pod restart at the cron hour doesn't double-hit the CDN. + if processed, err := store.IsDayProcessed(ctx, day); err == nil && processed { + Log.Info("cron skip already processed", "day", dayStr) + return + } + res, err := ProcessDay(ctx, store, builders, day, "cron", 16) + if err != nil { + Log.Error("cron run failed", "err", err) + MetricCronRuns.WithLabelValues("err").Inc() + return + } + MetricCronRuns.WithLabelValues("ok").Inc() + MetricLastRun.Set(float64(time.Now().Unix())) + Log.Info("cron run ok", "day", day.Format("2006-01-02"), "rows", res.Rows, "builders", res.Builders) + + pushCtx, cancel := context.WithTimeout(ctx, 60*time.Second) + defer cancel() + payload, err := buildPayload(pushCtx, store, builders, 0, "") + if err != nil { + Log.Error("post-cron payload build failed", "err", err) + return + } + if err := PushUpstash(pushCtx, payload); err != nil { + Log.Error("upstash push failed", "err", err) + } + refreshDBGauges(ctx, store) +} + +func refreshDBGauges(ctx context.Context, store *Store) { + s, err := store.Stats(ctx) + if err != nil { + return + } + MetricDBSize.Set(float64(s.DBSizeBytes)) + MetricBuildersCount.Set(float64(s.BuildersCount)) + MetricDaysCount.Set(float64(s.DaysCount)) + MetricLagHours.Set(s.LagHours) +} + diff --git a/harnesses/hl-archive/script/store.go b/harnesses/hl-archive/script/store.go new file mode 100644 index 00000000..5c7ee508 --- /dev/null +++ b/harnesses/hl-archive/script/store.go @@ -0,0 +1,318 @@ +// store.go — DuckDB schema, upserts, checkpoints, query API. +// +// All persistence lives here so the rest of the package can stay +// driver-agnostic (we may swap DuckDB for SQLite if CGO becomes a +// build pain on Railway). Two tables only — see CREATE statements. +// +// Concurrency: DuckDB serialises writes; the worker pool funnels +// per-day commits through a sync.Mutex to avoid two days racing on +// the same primary key. +package script + +import ( + "context" + "database/sql" + "errors" + "fmt" + "os" + "sort" + "sync" + "time" + + _ "github.com/marcboeker/go-duckdb/v2" +) + +const ( + schemaSQL = ` +CREATE TABLE IF NOT EXISTS builder_daily_aggregates ( + day DATE NOT NULL, + builder VARCHAR NOT NULL, + asset VARCHAR NOT NULL, + volume_usd DOUBLE NOT NULL, + fees_usd DOUBLE NOT NULL, + fill_count BIGINT NOT NULL, + unique_users BIGINT NOT NULL, + PRIMARY KEY (day, builder, asset) +); + +CREATE TABLE IF NOT EXISTS processed_days ( + day DATE PRIMARY KEY, + processed_at TIMESTAMP NOT NULL, + source VARCHAR NOT NULL, + row_count BIGINT NOT NULL, + builder_count INTEGER NOT NULL, + duration_ms BIGINT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_aggs_builder_day ON builder_daily_aggregates(builder, day); +CREATE INDEX IF NOT EXISTS idx_aggs_day ON builder_daily_aggregates(day); +` +) + +// Store wraps the DuckDB handle and a write mutex. +type Store struct { + db *sql.DB + path string + mu sync.Mutex +} + +func OpenStore(path string) (*Store, error) { + db, err := sql.Open("duckdb", path) + if err != nil { + return nil, fmt.Errorf("open duckdb at %s: %w", path, err) + } + db.SetMaxOpenConns(1) // DuckDB single-writer model + if _, err := db.ExecContext(context.Background(), schemaSQL); err != nil { + db.Close() + return nil, fmt.Errorf("apply schema: %w", err) + } + return &Store{db: db, path: path}, nil +} + +func (s *Store) Close() error { return s.db.Close() } + +// CommitDay atomically replaces the rows for (day, all builders in +// `rowsByBuilder`) and records a processed_days entry. +func (s *Store) CommitDay(ctx context.Context, day time.Time, source string, rowsByBuilder map[string][]AggRow, duration time.Duration) error { + s.mu.Lock() + defer s.mu.Unlock() + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + rollback := func() { _ = tx.Rollback() } + + dayStr := day.UTC().Format("2006-01-02") + // Wipe + insert per builder so a re-run of the same day is idempotent. + builders := make([]string, 0, len(rowsByBuilder)) + for b := range rowsByBuilder { + builders = append(builders, b) + } + sort.Strings(builders) + + totalRows := int64(0) + for _, b := range builders { + if _, err := tx.ExecContext(ctx, + `DELETE FROM builder_daily_aggregates WHERE day = ? AND builder = ?`, + dayStr, b); err != nil { + rollback() + return fmt.Errorf("delete day=%s builder=%s: %w", dayStr, b, err) + } + for _, r := range rowsByBuilder[b] { + if _, err := tx.ExecContext(ctx, + `INSERT INTO builder_daily_aggregates + (day, builder, asset, volume_usd, fees_usd, fill_count, unique_users) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + r.Key.Day, r.Key.Builder, r.Key.Asset, + r.VolumeUSD, r.FeesUSD, r.FillCount, r.UniqueUsers); err != nil { + rollback() + return fmt.Errorf("insert row: %w", err) + } + totalRows++ + } + } + + if _, err := tx.ExecContext(ctx, + `DELETE FROM processed_days WHERE day = ?`, dayStr); err != nil { + rollback() + return fmt.Errorf("delete processed_days: %w", err) + } + if _, err := tx.ExecContext(ctx, + `INSERT INTO processed_days (day, processed_at, source, row_count, builder_count, duration_ms) + VALUES (?, ?, ?, ?, ?, ?)`, + dayStr, time.Now().UTC(), source, totalRows, len(builders), duration.Milliseconds()); err != nil { + rollback() + return fmt.Errorf("insert processed_days: %w", err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit: %w", err) + } + return nil +} + +// OldestProcessedDay returns the smallest day in processed_days, formatted +// as YYYY-MM-DD. Empty string when the table is empty. Mirrors the MAX(day) +// path used by Stats so gap-fill can detect a left-of-range hole. +func (s *Store) OldestProcessedDay(ctx context.Context) (string, error) { + var oldest sql.NullTime + if err := s.db.QueryRowContext(ctx, + `SELECT MIN(day) FROM processed_days`).Scan(&oldest); err != nil && !errors.Is(err, sql.ErrNoRows) { + return "", err + } + if !oldest.Valid { + return "", nil + } + return oldest.Time.UTC().Format("2006-01-02"), nil +} + +// IsDayProcessed returns true if processed_days has a row for `day`. +func (s *Store) IsDayProcessed(ctx context.Context, day time.Time) (bool, error) { + var n int + err := s.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM processed_days WHERE day = ?`, + day.UTC().Format("2006-01-02")).Scan(&n) + if err != nil { + return false, err + } + return n > 0, nil +} + +// Wipe drops + recreates the schema. Used by `rebuild --confirm`. +func (s *Store) Wipe(ctx context.Context) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, err := s.db.ExecContext(ctx, `DROP TABLE IF EXISTS builder_daily_aggregates; DROP TABLE IF EXISTS processed_days;`); err != nil { + return err + } + _, err := s.db.ExecContext(ctx, schemaSQL) + return err +} + +// Stats summarises the DB for the health endpoint + Prom gauges. +type Stats struct { + DBSizeBytes int64 + BuildersCount int64 + DaysCount int64 + LastProcessedDay string // YYYY-MM-DD or "" + LagHours float64 +} + +func (s *Store) Stats(ctx context.Context) (Stats, error) { + out := Stats{} + if fi, err := os.Stat(s.path); err == nil { + out.DBSizeBytes = fi.Size() + } + if err := s.db.QueryRowContext(ctx, + `SELECT COUNT(DISTINCT builder) FROM builder_daily_aggregates`).Scan(&out.BuildersCount); err != nil { + return out, err + } + if err := s.db.QueryRowContext(ctx, + `SELECT COUNT(DISTINCT day) FROM builder_daily_aggregates`).Scan(&out.DaysCount); err != nil { + return out, err + } + var last sql.NullTime + if err := s.db.QueryRowContext(ctx, + `SELECT MAX(day) FROM processed_days`).Scan(&last); err != nil && !errors.Is(err, sql.ErrNoRows) { + return out, err + } + if last.Valid { + out.LastProcessedDay = last.Time.UTC().Format("2006-01-02") + out.LagHours = time.Since(last.Time.UTC()).Hours() + } + return out, nil +} + +// QueryBuilderTimeseries returns the per-day rollup for one builder +// over the last `days` days, ordered by day ascending. +func (s *Store) QueryBuilderTimeseries(ctx context.Context, builder string, days int) ([]TimePoint, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT CAST(day AS VARCHAR), SUM(volume_usd), SUM(fees_usd), SUM(fill_count) + FROM builder_daily_aggregates + WHERE builder = ? AND day >= CURRENT_DATE - ?::INTEGER + GROUP BY day ORDER BY day ASC`, + builder, days) + if err != nil { + return nil, err + } + defer rows.Close() + out := []TimePoint{} + for rows.Next() { + var p TimePoint + if err := rows.Scan(&p.Day, &p.Vol, &p.Fees, &p.Fills); err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} + +// TimePoint is one day in a builder's timeseries. +type TimePoint struct { + Day string `json:"day"` + Vol float64 `json:"vol"` + Fees float64 `json:"fees"` + Fills int64 `json:"fills"` +} + +// WindowAgg is the rollup over an arbitrary day window. +type WindowAgg struct { + VolumeUSD float64 `json:"volume_usd"` + FeesUSD float64 `json:"fees_usd"` + Fills int64 `json:"fills"` +} + +// QueryWindowsForAllBuilders returns, for each builder, the rollup over +// each supplied window in days. windowDays must be sorted ascending. +// The returned map is keyed by builder address. +func (s *Store) QueryWindowsForAllBuilders(ctx context.Context, windows map[string]int) (map[string]map[string]WindowAgg, error) { + out := map[string]map[string]WindowAgg{} + for label, days := range windows { + var rows *sql.Rows + var err error + if days <= 0 { // "all" + rows, err = s.db.QueryContext(ctx, + `SELECT builder, SUM(volume_usd), SUM(fees_usd), SUM(fill_count) + FROM builder_daily_aggregates GROUP BY builder`) + } else { + rows, err = s.db.QueryContext(ctx, + `SELECT builder, SUM(volume_usd), SUM(fees_usd), SUM(fill_count) + FROM builder_daily_aggregates + WHERE day >= CURRENT_DATE - ?::INTEGER + GROUP BY builder`, days) + } + if err != nil { + return nil, fmt.Errorf("window %s: %w", label, err) + } + for rows.Next() { + var b string + var w WindowAgg + if err := rows.Scan(&b, &w.VolumeUSD, &w.FeesUSD, &w.Fills); err != nil { + rows.Close() + return nil, err + } + if _, ok := out[b]; !ok { + out[b] = map[string]WindowAgg{} + } + out[b][label] = w + } + rows.Close() + } + return out, nil +} + +// QueryDailyTimeseriesAllBuilders returns per-builder per-day rows for +// the last `days` days (or all-time if days<=0). Bounded so the +// Upstash payload stays under the 1MB Vercel KV ceiling — callers +// should pass days <= 400. +func (s *Store) QueryDailyTimeseriesAllBuilders(ctx context.Context, days int) (map[string][]TimePoint, error) { + var rows *sql.Rows + var err error + if days <= 0 { + rows, err = s.db.QueryContext(ctx, + `SELECT builder, CAST(day AS VARCHAR), SUM(volume_usd), SUM(fees_usd), SUM(fill_count) + FROM builder_daily_aggregates + GROUP BY builder, day ORDER BY builder, day ASC`) + } else { + rows, err = s.db.QueryContext(ctx, + `SELECT builder, CAST(day AS VARCHAR), SUM(volume_usd), SUM(fees_usd), SUM(fill_count) + FROM builder_daily_aggregates + WHERE day >= CURRENT_DATE - ?::INTEGER + GROUP BY builder, day ORDER BY builder, day ASC`, days) + } + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string][]TimePoint{} + for rows.Next() { + var b string + var p TimePoint + if err := rows.Scan(&b, &p.Day, &p.Vol, &p.Fees, &p.Fills); err != nil { + return nil, err + } + out[b] = append(out[b], p) + } + return out, rows.Err() +} diff --git a/harnesses/hl-archive/script/store_test.go b/harnesses/hl-archive/script/store_test.go new file mode 100644 index 00000000..a93e71ea --- /dev/null +++ b/harnesses/hl-archive/script/store_test.go @@ -0,0 +1,144 @@ +package script + +import ( + "context" + "net/http" + "net/http/httptest" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" +) + +// TestOldestProcessedDay covers the empty + populated paths so the +// runCatchup gap-fill branch can rely on the helper. +func TestOldestProcessedDay(t *testing.T) { + dir := t.TempDir() + store, err := OpenStore(filepath.Join(dir, "oldest.duckdb")) + if err != nil { + t.Fatalf("open store: %v", err) + } + defer store.Close() + + got, err := store.OldestProcessedDay(context.Background()) + if err != nil { + t.Fatalf("empty: %v", err) + } + if got != "" { + t.Fatalf("expected empty oldest, got %q", got) + } + + for _, day := range []string{"2025-08-01", "2025-09-15", "2026-06-29"} { + if err := store.CommitDay(context.Background(), parseDay(day), "test", map[string][]AggRow{}, time.Millisecond); err != nil { + t.Fatalf("commit %s: %v", day, err) + } + } + got, err = store.OldestProcessedDay(context.Background()) + if err != nil { + t.Fatalf("populated: %v", err) + } + if got != "2025-08-01" { + t.Fatalf("expected 2025-08-01, got %q", got) + } +} + +// TestRunCatchup_GapFill seeds the store with 2025-08-01..2026-06-29, +// sets HL_ARCHIVE_BACKFILL_FROM=2025-07-10, and asserts the catchup +// goroutine fetches the 22 missing pre-Aug days from the CDN before +// falling through to the forward catchup. +func TestRunCatchup_GapFill(t *testing.T) { + body := lz4Encode(t, []byte(sampleCSV)) + var ( + mu sync.Mutex + hitDays = map[string]int{} + totalCalls atomic.Int64 + ) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + totalCalls.Add(1) + // URL path looks like /Mainnet/builder_fills//YYYYMMDD.csv.lz4 + // We slice the YYYYMMDD substring at fixed offset from the end. + p := r.URL.Path + const suffix = ".csv.lz4" + if len(p) < 8+len(suffix) || p[len(p)-len(suffix):] != suffix { + http.NotFound(w, r) + return + } + ymd := p[len(p)-8-len(suffix) : len(p)-len(suffix)] + day := ymd[0:4] + "-" + ymd[4:6] + "-" + ymd[6:8] + mu.Lock() + hitDays[day]++ + mu.Unlock() + w.Write(body) + })) + defer srv.Close() + + prev := HTTPClient + defer func() { HTTPClient = prev }() + HTTPClient = srv.Client() + HTTPClient.Transport = &redirectingTransport{target: srv.URL} + + dir := t.TempDir() + dbPath := filepath.Join(dir, "gapfill.duckdb") + t.Setenv("HL_ARCHIVE_DB_PATH", dbPath) + t.Setenv("HL_ARCHIVE_BACKFILL_FROM", "2025-07-10") + + store, err := OpenStore(dbPath) + if err != nil { + t.Fatalf("open store: %v", err) + } + defer store.Close() + + // Seed processed_days with a synthetic range 2025-08-01..2026-06-29 + // so the gap (2025-07-10..2025-07-31) is the only missing window + // below today's bound. We commit empty row maps which is fine for + // the catchup gap-detection logic — it only reads MIN/MAX(day). + for d := parseDay("2025-08-01"); !d.After(parseDay("2026-06-29")); d = d.AddDate(0, 0, 1) { + if err := store.CommitDay(context.Background(), d, "seed", map[string][]AggRow{}, time.Millisecond); err != nil { + t.Fatalf("seed %s: %v", d.Format("2006-01-02"), err) + } + } + + // Pin "today" so the forward catchup walks 2026-06-30 .. today-1. + // We can't override time.Now() without refactoring server.go, so + // instead we count gap-fill hits explicitly and only assert on the + // 22 pre-Aug days. Forward catchup will hit additional days but + // that's fine — the assertion is "gap days were all visited". + builders := []Builder{{ + Slug: "test", Name: "Test", + Address: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }} + + runCatchup(context.Background(), store, builders) + + mu.Lock() + defer mu.Unlock() + for d := parseDay("2025-07-10"); d.Before(parseDay("2025-08-01")); d = d.AddDate(0, 0, 1) { + key := d.Format("2006-01-02") + if hitDays[key] == 0 { + t.Errorf("expected gap-fill to fetch %s, but CDN was not hit", key) + } + } + + // Sanity check: exactly 22 gap days requested before any post-existing + // forward catchup work. + gapHits := 0 + for d := parseDay("2025-07-10"); d.Before(parseDay("2025-08-01")); d = d.AddDate(0, 0, 1) { + gapHits += hitDays[d.Format("2006-01-02")] + } + if gapHits != 22 { + t.Errorf("expected 22 gap-fill fetches, got %d", gapHits) + } + + // Existing range must remain processed (gap-fill did not wipe it). + oldest, err := store.OldestProcessedDay(context.Background()) + if err != nil { + t.Fatalf("oldest after: %v", err) + } + if oldest != "2025-07-10" { + t.Errorf("expected oldest=2025-07-10 after gap-fill, got %s", oldest) + } + if totalCalls.Load() == 0 { + t.Errorf("CDN was never hit") + } +} diff --git a/harnesses/hl-archive/script/testutil_test.go b/harnesses/hl-archive/script/testutil_test.go new file mode 100644 index 00000000..409d2833 --- /dev/null +++ b/harnesses/hl-archive/script/testutil_test.go @@ -0,0 +1,11 @@ +package script + +import "time" + +// timeStub re-exports time.Time so the test files can stay terse. +type timeStub = time.Time + +func parseDay(s string) time.Time { + t, _ := time.Parse("2006-01-02", s) + return t +} diff --git a/harnesses/hl-archive/script/upstash.go b/harnesses/hl-archive/script/upstash.go new file mode 100644 index 00000000..2df1e074 --- /dev/null +++ b/harnesses/hl-archive/script/upstash.go @@ -0,0 +1,91 @@ +// upstash.go — REST push to Upstash Redis (no Redis client needed). +// +// Upstash exposes a plain HTTPS endpoint (SET key value) that we hit +// via net/http. Keeping the surface tiny means we don't pull in +// go-redis and its transitive deps for a single SET per day. +package script + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" +) + +// UpstashPayload is the shape the OCB consumer (Next.js page) reads. +type UpstashPayload struct { + UpdatedAt string `json:"updated_at"` + Builders map[string]UpstashBuilder `json:"builders"` +} + +type UpstashBuilder struct { + Name string `json:"name"` + Windows map[string]WindowAgg `json:"windows"` + TimeseriesDaily []TimePoint `json:"timeseries_daily"` +} + +// upstashTimeseriesDaysCap is the hard cap on per-builder daily points +// pushed to Upstash. Sized so the worst case (≈110 builders × 90 days +// × ~60 B/row ≈ 600 KB) stays well under the 1 MB Vercel KV free-tier +// ceiling. Window aggregates (24h..all) keep full coverage because +// they're constant-size per builder. +const upstashTimeseriesDaysCap = 90 + +// PushUpstash sends the payload to the configured Upstash key. No-op +// (returns nil) when UPSTASH_REDIS_REST_URL/TOKEN are unset so local +// dev runs don't fail. +func PushUpstash(ctx context.Context, payload UpstashPayload) error { + base := strings.TrimRight(strings.TrimSpace(os.Getenv("UPSTASH_REDIS_REST_URL")), "/") + token := strings.TrimSpace(os.Getenv("UPSTASH_REDIS_REST_TOKEN")) + if base == "" || token == "" { + Log.Warn("upstash disabled (env not set)") + return nil + } + key := strings.TrimSpace(os.Getenv("HL_ARCHIVE_UPSTASH_KEY")) + if key == "" { + key = "ocb:hl-archive:v1" + } + + // Cap per-builder daily series before serialising. QueryDailyTimeseriesAllBuilders + // orders rows ASC, so the most recent N live at the tail. + for addr, b := range payload.Builders { + if n := len(b.TimeseriesDaily); n > upstashTimeseriesDaysCap { + b.TimeseriesDaily = b.TimeseriesDaily[n-upstashTimeseriesDaysCap:] + payload.Builders[addr] = b + } + } + + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal payload: %w", err) + } + + endpoint := fmt.Sprintf("%s/set/%s", base, url.PathEscape(key)) + start := time.Now() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + MetricUpstashPushDur.Observe(time.Since(start).Seconds()) + if err != nil { + return fmt.Errorf("upstash post: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return fmt.Errorf("upstash %d: %s", resp.StatusCode, string(b)) + } + Log.Info("upstash push ok", "key", key, "bytes", len(body)) + return nil +} diff --git a/harnesses/hl-archive/tests/integration/README.md b/harnesses/hl-archive/tests/integration/README.md new file mode 100644 index 00000000..df9b444b --- /dev/null +++ b/harnesses/hl-archive/tests/integration/README.md @@ -0,0 +1,65 @@ +# hl-archive integration tests + +Smoke test for the `hl-archive` binary. The CDN parse path is covered by the in-process Go integration test (`script/integration_test.go`); this shell driver covers the binary surface (`/health`, `/v1/aggregates` auth, `/metrics`) by booting the real binary against a scratch DuckDB. + +## What `smoke.sh` does + +1. Runs `go test ./script/...`. The `TestProcessDay_EndToEnd` case in `integration_test.go` stands up an in-process `httptest` CDN, serves a fixture LZ4 payload, and exercises parse -> aggregate -> DuckDB commit -> `Stats()`. +2. Builds the binary (`go build -o /tmp/hl-archive ./cmd/hl-archive`). +3. Boots `hl-archive serve` against a fresh empty DuckDB on a tempdir, with a random API key. +4. Asserts: + - `GET /health` returns 200 and a payload with `status`, `db_size_bytes`, `builders_count`, `days_count`, `version`. + - `GET /v1/aggregates?window=30d` returns 401 without `X-API-Key`. + - Same call with `X-API-Key` returns 200 and an `{updated_at, builders}` payload (`builders` is an empty object on a fresh DB). + - `GET /metrics` exposes the `hl_archive_*` family. +5. Tears the server down. + +The shell smoke does NOT exercise the CDN parse layer. The CDN URL is a Go const (`script/parse.go` -> `cdnURLTemplate`), so a shell-level mock CDN cannot reach the parser without code changes. Once the binary grows an `HL_ARCHIVE_CDN_BASE` env hook, the fixtures under `fixtures/csv/` can drive a full backfill from shell. + +## Layout + +```text +tests/integration/ + README.md + smoke.sh + fixtures/ + csv/ # CSV templates, mirror of the HL CDN tree + 0xb84168cf3be63c6b8dad05ff5d755e97432ff80b/ + 20260620.csv + 20260621.csv + 20260622.csv + 0x1cc34f6af34653c515b47a83e1de70ba9b0cda1f/ + 20260620.csv + 20260621.csv + 20260622.csv + expected/ + aggregates_30d.json # what /v1/aggregates should return once the fixtures are loaded +``` + +The fixture CSVs use the same `time,user,coin,px,sz,builder_fee` header as the real CDN files, and the same lowercased-address directory layout. Each file is six rows across three assets (BTC, ETH, SOL or ARB) with deterministic users so the aggregates are easy to recompute by hand. + +## Requirements + +- `go` >= 1.24 +- `jq` +- `curl` +- A free TCP port at `:2114` + +## Run + +```bash +cd miniapps/hl-archive +bash tests/integration/smoke.sh +``` + +Exit code 0 means the smoke passed. Non-zero prints the failed assertion. + +## CI + +The smoke is meant to run in PR CI on every change under `miniapps/hl-archive/`. It is intentionally self-contained: no Docker, no Upstash, no CDN access required. + +## Adding a fixture day + +1. Drop a new file `fixtures/csv//.csv` with the same header as the others. +2. Recompute the expected aggregates and update `fixtures/expected/aggregates_30d.json` by hand (the file is small). +3. Once the binary grows `HL_ARCHIVE_CDN_BASE`, extend `smoke.sh` to spin up a `python3 -m http.server` on the fixtures dir, run `hl-archive backfill --from --to`, and diff the live `/v1/aggregates` response against the expected fixture. diff --git a/harnesses/hl-archive/tests/integration/fixtures/csv/0x1cc34f6af34653c515b47a83e1de70ba9b0cda1f/20260620.csv b/harnesses/hl-archive/tests/integration/fixtures/csv/0x1cc34f6af34653c515b47a83e1de70ba9b0cda1f/20260620.csv new file mode 100644 index 00000000..424850fa --- /dev/null +++ b/harnesses/hl-archive/tests/integration/fixtures/csv/0x1cc34f6af34653c515b47a83e1de70ba9b0cda1f/20260620.csv @@ -0,0 +1,7 @@ +time,user,coin,px,sz,builder_fee +1750377600,0xbbbb000000000000000000000000000000000001,BTC,65010.0,0.50,16.25 +1750381200,0xbbbb000000000000000000000000000000000002,BTC,65110.0,0.40,13.02 +1750384800,0xbbbb000000000000000000000000000000000003,ETH,3505.0,5.00,8.76 +1750388400,0xbbbb000000000000000000000000000000000004,ETH,3515.0,2.00,3.51 +1750392000,0xbbbb000000000000000000000000000000000001,SOL,150.2,30.0,2.25 +1750395600,0xbbbb000000000000000000000000000000000005,SOL,150.7,15.0,1.13 diff --git a/harnesses/hl-archive/tests/integration/fixtures/csv/0x1cc34f6af34653c515b47a83e1de70ba9b0cda1f/20260621.csv b/harnesses/hl-archive/tests/integration/fixtures/csv/0x1cc34f6af34653c515b47a83e1de70ba9b0cda1f/20260621.csv new file mode 100644 index 00000000..fe422b39 --- /dev/null +++ b/harnesses/hl-archive/tests/integration/fixtures/csv/0x1cc34f6af34653c515b47a83e1de70ba9b0cda1f/20260621.csv @@ -0,0 +1,7 @@ +time,user,coin,px,sz,builder_fee +1750464000,0xbbbb000000000000000000000000000000000006,BTC,65510.0,0.25,8.19 +1750467600,0xbbbb000000000000000000000000000000000002,BTC,65610.0,0.75,24.60 +1750471200,0xbbbb000000000000000000000000000000000007,ETH,3555.0,4.00,7.11 +1750474800,0xbbbb000000000000000000000000000000000003,SOL,152.1,12.0,0.91 +1750478400,0xbbbb000000000000000000000000000000000008,SOL,152.6,6.00,0.46 +1750482000,0xbbbb000000000000000000000000000000000004,ARB,1.21,2500.0,0.15 diff --git a/harnesses/hl-archive/tests/integration/fixtures/csv/0x1cc34f6af34653c515b47a83e1de70ba9b0cda1f/20260622.csv b/harnesses/hl-archive/tests/integration/fixtures/csv/0x1cc34f6af34653c515b47a83e1de70ba9b0cda1f/20260622.csv new file mode 100644 index 00000000..db1040ed --- /dev/null +++ b/harnesses/hl-archive/tests/integration/fixtures/csv/0x1cc34f6af34653c515b47a83e1de70ba9b0cda1f/20260622.csv @@ -0,0 +1,7 @@ +time,user,coin,px,sz,builder_fee +1750550400,0xbbbb000000000000000000000000000000000009,BTC,66010.0,0.50,16.50 +1750554000,0xbbbb000000000000000000000000000000000001,BTC,66110.0,0.20,6.61 +1750557600,0xbbbb000000000000000000000000000000000009,ETH,3605.0,5.00,9.01 +1750561200,0xbbbb000000000000000000000000000000000002,ETH,3615.0,2.00,3.61 +1750564800,0xbbbb000000000000000000000000000000000003,SOL,153.1,40.0,3.06 +1750568400,0xbbbb000000000000000000000000000000000007,SOL,153.6,8.00,0.61 diff --git a/harnesses/hl-archive/tests/integration/fixtures/csv/0xb84168cf3be63c6b8dad05ff5d755e97432ff80b/20260620.csv b/harnesses/hl-archive/tests/integration/fixtures/csv/0xb84168cf3be63c6b8dad05ff5d755e97432ff80b/20260620.csv new file mode 100644 index 00000000..3953154e --- /dev/null +++ b/harnesses/hl-archive/tests/integration/fixtures/csv/0xb84168cf3be63c6b8dad05ff5d755e97432ff80b/20260620.csv @@ -0,0 +1,7 @@ +time,user,coin,px,sz,builder_fee +1750377600,0xaaaa000000000000000000000000000000000001,BTC,65000.0,0.10,3.25 +1750381200,0xaaaa000000000000000000000000000000000002,BTC,65100.0,0.20,6.51 +1750384800,0xaaaa000000000000000000000000000000000003,ETH,3500.0,1.50,2.63 +1750388400,0xaaaa000000000000000000000000000000000001,ETH,3510.0,0.50,0.88 +1750392000,0xaaaa000000000000000000000000000000000004,SOL,150.0,10.0,0.75 +1750395600,0xaaaa000000000000000000000000000000000005,SOL,150.5,5.0,0.38 diff --git a/harnesses/hl-archive/tests/integration/fixtures/csv/0xb84168cf3be63c6b8dad05ff5d755e97432ff80b/20260621.csv b/harnesses/hl-archive/tests/integration/fixtures/csv/0xb84168cf3be63c6b8dad05ff5d755e97432ff80b/20260621.csv new file mode 100644 index 00000000..ef01fa7e --- /dev/null +++ b/harnesses/hl-archive/tests/integration/fixtures/csv/0xb84168cf3be63c6b8dad05ff5d755e97432ff80b/20260621.csv @@ -0,0 +1,7 @@ +time,user,coin,px,sz,builder_fee +1750464000,0xaaaa000000000000000000000000000000000001,BTC,65500.0,0.05,1.64 +1750467600,0xaaaa000000000000000000000000000000000006,BTC,65600.0,0.30,9.84 +1750471200,0xaaaa000000000000000000000000000000000002,ETH,3550.0,2.00,3.55 +1750474800,0xaaaa000000000000000000000000000000000007,SOL,152.0,8.00,0.61 +1750478400,0xaaaa000000000000000000000000000000000007,SOL,152.5,2.00,0.15 +1750482000,0xaaaa000000000000000000000000000000000003,ARB,1.20,1000.0,0.06 diff --git a/harnesses/hl-archive/tests/integration/fixtures/csv/0xb84168cf3be63c6b8dad05ff5d755e97432ff80b/20260622.csv b/harnesses/hl-archive/tests/integration/fixtures/csv/0xb84168cf3be63c6b8dad05ff5d755e97432ff80b/20260622.csv new file mode 100644 index 00000000..dde2ceea --- /dev/null +++ b/harnesses/hl-archive/tests/integration/fixtures/csv/0xb84168cf3be63c6b8dad05ff5d755e97432ff80b/20260622.csv @@ -0,0 +1,7 @@ +time,user,coin,px,sz,builder_fee +1750550400,0xaaaa000000000000000000000000000000000008,BTC,66000.0,0.15,4.95 +1750554000,0xaaaa000000000000000000000000000000000001,BTC,66100.0,0.10,3.31 +1750557600,0xaaaa000000000000000000000000000000000009,ETH,3600.0,3.00,5.40 +1750561200,0xaaaa000000000000000000000000000000000009,ETH,3610.0,1.00,1.81 +1750564800,0xaaaa000000000000000000000000000000000002,SOL,153.0,20.0,1.53 +1750568400,0xaaaa000000000000000000000000000000000003,SOL,153.5,4.00,0.31 diff --git a/harnesses/hl-archive/tests/integration/fixtures/expected/aggregates_30d.json b/harnesses/hl-archive/tests/integration/fixtures/expected/aggregates_30d.json new file mode 100644 index 00000000..62472cc8 --- /dev/null +++ b/harnesses/hl-archive/tests/integration/fixtures/expected/aggregates_30d.json @@ -0,0 +1,28 @@ +{ + "window": "30d", + "as_of_day": "2026-06-22", + "builders": [ + { + "address": "0x1cc34f6af34653c515b47a83e1de70ba9b0cda1f", + "slug": "axiom", + "name": "Axiom", + "volume_usd": 254276.1, + "fees_usd": 125.74, + "fills": 18, + "unique_users": 9, + "effective_fee_bps": 4.9450, + "fees_per_user_usd": 13.9711 + }, + { + "address": "0xb84168cf3be63c6b8dad05ff5d755e97432ff80b", + "slug": "phantom-perps", + "name": "Phantom", + "volume_usd": 96147.5, + "fees_usd": 47.56, + "fills": 18, + "unique_users": 9, + "effective_fee_bps": 4.9466, + "fees_per_user_usd": 5.2844 + } + ] +} diff --git a/harnesses/hl-archive/tests/integration/smoke.sh b/harnesses/hl-archive/tests/integration/smoke.sh new file mode 100755 index 00000000..e0d879c6 --- /dev/null +++ b/harnesses/hl-archive/tests/integration/smoke.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +# smoke.sh - end-to-end smoke test for hl-archive +# +# Two layers: +# +# 1. go test ./script/... covers the parse -> store -> stats path +# via the in-process httptest CDN in integration_test.go. That +# proves LZ4 decode, CSV streaming, DuckDB upsert and the daily +# Stats() snapshot are all wired together. +# +# 2. This shell driver covers the binary surface: it builds +# hl-archive, boots `serve` against a scratch DuckDB, and asserts +# a) /health returns 200 with the documented JSON shape +# b) /v1/aggregates returns 401 without X-API-Key +# c) /v1/aggregates returns 200 + UpstashPayload shape with it +# d) /metrics exposes the hl_archive_* family +# +# Layer 2 does NOT hit the CDN: the binary cannot currently be pointed +# at a mock CDN from shell (the CDN URL is a Go const). Backfill +# coverage lives in layer 1. +# +# Fixture CSV templates live under fixtures/csv//.csv +# so the same data can be reused once the binary grows an +# HL_ARCHIVE_CDN_BASE env hook (see README "Adding a fixture day"). +# +# Run from the repo: bash tests/integration/smoke.sh +# +# Exit codes: +# 0 smoke passed +# 1 smoke failed (last assertion printed) +# 2 prerequisite missing + +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$HERE/../.." && pwd)" +WORK="$(mktemp -d -t hl-archive-smoke.XXXXXX)" +BIN="$WORK/hl-archive" +DB="$WORK/history.duckdb" +API_PORT=2114 +API_KEY="smoke-key-$$" + +cleanup() { + local code=$? + set +e + [[ -n "${SERVE_PID:-}" ]] && kill "$SERVE_PID" 2>/dev/null + wait 2>/dev/null + rm -rf "$WORK" + exit $code +} +trap cleanup EXIT INT TERM + +require() { + command -v "$1" >/dev/null 2>&1 || { echo "missing required tool: $1" >&2; exit 2; } +} + +require go +require jq +require curl + +echo "[1/5] go test ./script/... (parse + store + stats)" +(cd "$ROOT" && go test ./script/...) + +echo "[2/5] building hl-archive..." +(cd "$ROOT" && go build -o "$BIN" ./cmd/hl-archive) + +# Make sure the listener port is free. +if lsof -iTCP:$API_PORT -sTCP:LISTEN >/dev/null 2>&1; then + echo "port :$API_PORT already in use, aborting" >&2 + exit 2 +fi + +export HL_ARCHIVE_DB_PATH="$DB" +export HL_ARCHIVE_BUILDERS_FILE="$ROOT/data/builders.json" +export HL_ARCHIVE_HTTP_ADDR="127.0.0.1:$API_PORT" +export HL_ARCHIVE_API_KEY="$API_KEY" +export LOG_LEVEL=warn +# Skip Upstash push: leaving the URL/TOKEN unset turns the call into a +# warn-and-return inside script/upstash.go. +unset UPSTASH_REDIS_REST_URL UPSTASH_REDIS_REST_TOKEN + +echo "[3/5] booting serve on :$API_PORT (empty DB) ..." +"$BIN" serve >"$WORK/serve.log" 2>&1 & +SERVE_PID=$! +for i in 1 2 3 4 5 6 7 8 9 10; do + curl -sf "http://127.0.0.1:$API_PORT/health" >/dev/null && break + sleep 0.3 +done +if ! curl -sf "http://127.0.0.1:$API_PORT/health" >/dev/null; then + echo "serve did not come up on :$API_PORT, log tail:" >&2 + tail -n 50 "$WORK/serve.log" >&2 + exit 1 +fi + +echo "[4/5] asserting API contract..." + +# (a) /health shape +HEALTH=$(curl -sf "http://127.0.0.1:$API_PORT/health") +echo "$HEALTH" | jq -e ' + .status and + (.db_size_bytes | type == "number") and + (.builders_count | type == "number") and + (.days_count | type == "number") and + (.version | type == "string") +' >/dev/null || { echo "/health shape mismatch: $HEALTH"; exit 1; } +echo " /health OK: $(echo "$HEALTH" | jq -c '{status, days_count, builders_count}')" + +# (b) /v1/aggregates without key -> 401 +CODE=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:$API_PORT/v1/aggregates?window=30d") +[[ "$CODE" == "401" ]] || { echo "expected 401 without key, got $CODE"; exit 1; } +echo " /v1/aggregates 401 without X-API-Key OK" + +# (c) /v1/aggregates with key -> 200 + payload shape +AGG=$(curl -sf -H "X-API-Key: $API_KEY" "http://127.0.0.1:$API_PORT/v1/aggregates?window=30d") +echo "$AGG" | jq -e ' + (.updated_at | type == "string") and + (.builders | type == "object") +' >/dev/null || { echo "/v1/aggregates shape mismatch: $AGG"; exit 1; } +echo " /v1/aggregates 200 with X-API-Key OK" + +# (d) /metrics exposes the hl_archive_* family +METRICS=$(curl -sf "http://127.0.0.1:$API_PORT/metrics") +for m in hl_archive_db_size_bytes hl_archive_builders_count hl_archive_days_count \ + hl_archive_lag_hours hl_archive_http_requests_total; do + echo "$METRICS" | grep -qE "^# HELP $m " \ + || { echo "/metrics missing $m"; exit 1; } +done +echo " /metrics exposes hl_archive_* family OK" + +echo "[5/5] smoke OK" diff --git a/harnesses/perp-cohort-stats/.env.example b/harnesses/perp-cohort-stats/.env.example new file mode 100644 index 00000000..d33ca8e0 --- /dev/null +++ b/harnesses/perp-cohort-stats/.env.example @@ -0,0 +1,8 @@ +# Mobula API key (optional: enables the Mobula cross-check source) +MOBULA_API_KEY= +# Token protecting GET /logs (loghub). Unset disables the endpoint. +LOGS_TOKEN= +# Comma-separated venue slugs to fetch funding for via Mobula (optional) +MOBULA_FUNDING_VENUES= +# Main loop interval in seconds (default 300) +TICK_INTERVAL_SECONDS= diff --git a/harnesses/perp-cohort-stats/.gitignore b/harnesses/perp-cohort-stats/.gitignore new file mode 100644 index 00000000..3cb82662 --- /dev/null +++ b/harnesses/perp-cohort-stats/.gitignore @@ -0,0 +1 @@ +miniapps/perp-cohort-stats/script diff --git a/harnesses/perp-cohort-stats/Dockerfile b/harnesses/perp-cohort-stats/Dockerfile new file mode 100644 index 00000000..dd4ff692 --- /dev/null +++ b/harnesses/perp-cohort-stats/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 || true + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux go build -o /app/perp-cohort-stats ./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/perp-cohort-stats /app/perp-cohort-stats + +EXPOSE 2112 + +CMD ["/app/perp-cohort-stats"] diff --git a/harnesses/perp-cohort-stats/README.md b/harnesses/perp-cohort-stats/README.md new file mode 100644 index 00000000..31ac5fee --- /dev/null +++ b/harnesses/perp-cohort-stats/README.md @@ -0,0 +1,106 @@ +# perp-cohort-stats + +Per-venue perp-DEX cohort exporter for the OCB perp benches and +`/benches/perp-*` product pages. + +## What it does + +Polls Hyperliquid info, Lighter mainnet info, DefiLlama protocol pages, +and the Mobula CEFI funding-rate aggregator on a single 60 s sweep, +applies a per-metric source priority, cross-checks divergent sources, +and exposes Prometheus gauges on `:2112/metrics` that the OCB site +reads on every SSR render of the perp pages. + +## Sources & gauges + +| Gauge | Primary source | Fallback | +|---|---|---| +| `perp_venue_volume_24h_usd{venue}` | HL native / Lighter native | DefiLlama HTML | +| `perp_venue_volume_30d_usd{venue}` | DefiLlama HTML | (none) | +| `perp_venue_oi_usd{venue}` | HL native / Lighter native | DefiLlama HTML | +| `perp_venue_fees_30d_usd{venue}` | DefiLlama HTML | (none) | +| `perp_venue_active_markets{venue}` | HL native / Lighter native | DefiLlama HTML | +| `perp_venue_top_market_volume_24h_usd{venue}` | HL native / Lighter native | (none) | +| `perp_venue_health{venue}` | derived per tick | n/a | +| `perp_venue_funding_24h_bps{venue, asset}` | Mobula funding-rate | (none) | +| `perp_venue_funding_interval_hours{venue, asset}` | Mobula funding-rate | (none) | + +Plus observability: + +- `perp_venue_last_refresh_unix{venue, source}` +- `perp_cohort_stats_source_used{venue, metric, source}` (1 for the source that served the value) +- `perp_cohort_stats_fetch_errors_total{venue, source, error_type}` +- `perp_cohort_stats_data_divergence_total{venue, metric}` +- `perp_cohort_stats_last_tick_unix` + +## Venue registry + +The set of venues is hardcoded in `cmd/script/registry.go`. It MUST +mirror the OCB site's perp venue registry. Adding a new venue: + +1. Append to `Registry` in `cmd/script/registry.go` +2. Add per-metric priority entries in `adapter.go priorityMap()` +3. Append on the OCB site +4. Redeploy both + +Today's set: + +| Slug | Name | Chain | +|---|---|---| +| hyperliquid | Hyperliquid | hyperliquid | +| lighter | Lighter | zksync | +| gmx-v2 | GMX V2 | arbitrum | + +## Env vars + +| Var | Default | Required | +|---|---|---| +| `TICK_INTERVAL_SECONDS` | `60` | Optional override | +| `MOBULA_API_KEY` | hardcoded fallback | Optional override | +| `MOBULA_FUNDING_VENUES` | 12-venue CEFI cohort | Optional override | + +## Port + +Hardcoded `:2112` per the OCB harness convention. The shared Prom +gateway on Railway is configured to scrape `:2112` from every OCB +harness. Do not listen on `$PORT`; Railway sets that env var for its +proxy layer and the harness ignores it. + +## Graceful degradation + +- Any single source 4xx / 5xx -> `perp_cohort_stats_fetch_errors_total` + bucketed by `error_type`. The router falls through to the next source + in the priority list for that (venue, metric). +- Full miss for a (venue, metric) -> in-memory carry-forward + republishes the last known value but does NOT advance + `perp_venue_last_refresh_unix`, so the UI can compute true age. +- Cross-check between primary and secondary: when both return non-zero + and disagree by more than 10 percent, increment + `perp_cohort_stats_data_divergence_total{venue, metric}`. Publication + always picks the primary; the counter is informational. +- Each source runs in its own goroutine on every sweep with a 15 s HTTP + timeout. One source going down does not block any other source. + +## Local run + +```bash +TICK_INTERVAL_SECONDS=60 go run ./cmd/script + +# In another terminal: +curl -s http://localhost:2112/metrics | grep '^perp_venue_' | head +``` + +## Build & deploy + +The Dockerfile mirrors `pm-cohort-stats`: + +``` +docker build -t perp-cohort-stats . +docker run -p 2112:2112 perp-cohort-stats +``` + +Railway: connect to the `feat/perp-cohort-stats` branch, set +`PROMETHEUS_SCRAPE_ENABLED=true` on the shared Prom gateway (or wire +the new service into the Caddy sidecar config used by the other OCB +harnesses), and verify `/metrics` returns non-zero `perp_venue_*` +families. diff --git a/harnesses/perp-cohort-stats/cmd/script/adapter.go b/harnesses/perp-cohort-stats/cmd/script/adapter.go new file mode 100644 index 00000000..e15922bb --- /dev/null +++ b/harnesses/perp-cohort-stats/cmd/script/adapter.go @@ -0,0 +1,618 @@ +package main + +import ( + "fmt" + "math" + "sync" + "time" +) + +// Source is the minimal contract a fetcher implements. Each source +// independently knows how to populate one or more (venue, metric) +// pairs. The router below picks the first source whose Fetch returns +// a non-nil value, per the priority map per metric. +// +// Implementations write directly into a *SourceResult and never block +// the harness loop on errors: any failure increments the fetch-errors +// counter and returns nil for the metrics it could not populate. +type Source interface { + Name() string + // Fetch executes one call (or batch of calls) and returns a + // SourceResult keyed by (venue, metric). Missing keys mean the + // source had no opinion on that pair (e.g. Lighter native does not + // publish fees, so it never returns a "fees_30d" key). + Fetch() (*SourceResult, error) +} + +// SourceResult is the structured output of a Source.Fetch: a per-venue +// map of metric -> value plus per-(venue, asset) funding maps. We use +// composite metric keys (e.g. "volume_24h", "oi", "fees_30d") that +// match the router's priority map. +type SourceResult struct { + // Values is keyed [venue][metric] -> float64. + Values map[string]map[string]float64 + // Funding is keyed [venue][asset] -> (bps_24h, intervalHours). + Funding map[string]map[string]fundingPoint +} + +type fundingPoint struct { + Bps24h float64 + IntervalHours float64 +} + +func newSourceResult() *SourceResult { + return &SourceResult{ + Values: map[string]map[string]float64{}, + Funding: map[string]map[string]fundingPoint{}, + } +} + +func (r *SourceResult) Set(venue, metric string, v float64) { + if r.Values[venue] == nil { + r.Values[venue] = map[string]float64{} + } + r.Values[venue][metric] = v +} + +// SetIfPositive only writes the metric when v > 0. Native sources use +// this to avoid publishing a zero (which the router would treat as a +// real value and use to overwrite carry-forward). A truly-zero venue +// either has no markets, is dead, or its API hiccuped: in all three +// cases carry-forward is more correct than publishing 0. +func (r *SourceResult) SetIfPositive(venue, metric string, v float64) { + if v <= 0 { + return + } + r.Set(venue, metric, v) +} + +func (r *SourceResult) SetFunding(venue, asset string, p fundingPoint) { + if r.Funding[venue] == nil { + r.Funding[venue] = map[string]fundingPoint{} + } + r.Funding[venue][asset] = p +} + +// Metric names used in the priority map. These are internal-only; +// the published Prom metric names live in metrics.go. +const ( + mVolume24h = "volume_24h" + mVolume30d = "volume_30d" + mOI = "oi" + mFees30d = "fees_30d" + mActiveMarkets = "active_markets" + mTopVol24h = "top_market_volume_24h" +) + +// Source names. Keep these stable: they appear as Prom label values +// on perp_venue_last_refresh_unix{source=...} and +// perp_cohort_stats_source_used{source=...}. +const ( + srcHLNative = "hl_native" + srcLighterNative = "lighter_native" + srcDydxNative = "dydx_native" + srcParadexNative = "paradex_native" + srcEdgexNative = "edgex_native" + srcAsterNative = "aster_native" + srcVertexNative = "vertex_native" + srcGrvtNative = "grvt_native" + srcExtendedNative = "extended_native" + srcAevoNative = "aevo_native" + srcPacificaNative = "pacifica_native" + srcVariationalNative = "variational_native" + srcOstiumNative = "ostium_native" + srcDefillama = "defillama" + srcMobulaPairs = "mobula_pairs" + srcMobulaFund = "mobula_funding" +) + +// priorityMap returns the ordered source preference for (venue, metric). +// First source returning a non-nil value wins; remaining sources still +// run (so cross-check can fire) but their value is not published. +func priorityMap(venue, metric string) []string { + switch metric { + case mVolume24h: + switch venue { + case "hyperliquid": + return []string{srcHLNative, srcDefillama} + case "lighter": + return []string{srcLighterNative, srcDefillama} + case "gmx-v2", "gains": + return []string{srcDefillama} + case "dydx": + return []string{srcDydxNative, srcDefillama} + case "paradex": + return []string{srcParadexNative, srcDefillama} + case "edgex": + return []string{srcEdgexNative, srcDefillama} + case "aster": + return []string{srcAsterNative, srcDefillama} + case "vertex": + return []string{srcVertexNative, srcDefillama} + case "grvt": + return []string{srcGrvtNative, srcDefillama} + case "extended": + return []string{srcExtendedNative, srcDefillama} + case "aevo": + return []string{srcAevoNative, srcDefillama} + case "pacifica": + return []string{srcPacificaNative, srcDefillama} + case "variational": + // Native REST blocked (see source_variational.go). + // DefiLlama is the only path. + return []string{srcDefillama} + case "ostium": + // Subgraph exposes OI but no 24h rolling volume; defer + // vol_24h to DefiLlama. + return []string{srcDefillama} + } + case mVolume30d: + switch venue { + case "hyperliquid": + return []string{srcHLNative, srcDefillama} + case "lighter": + return []string{srcLighterNative, srcDefillama} + case "gmx-v2", "gains": + return []string{srcDefillama} + case "vertex": + // DefiLlama vertex-perps returns null for vol30d, so the + // native archive (31 daily granules diffed) is primary. + return []string{srcVertexNative, srcDefillama} + case "dydx", "paradex", "edgex", "aster", "grvt", + "extended", "aevo", "pacifica", "variational", "ostium": + // No native 30d aggregate exposed on these venues; rely + // on DefiLlama for the trailing-window number. + return []string{srcDefillama} + } + case mOI: + switch venue { + case "hyperliquid": + return []string{srcHLNative, srcDefillama} + case "lighter": + return []string{srcLighterNative, srcDefillama} + case "gmx-v2", "gains": + return []string{srcDefillama} + case "dydx": + return []string{srcDydxNative, srcDefillama} + case "paradex": + return []string{srcParadexNative, srcDefillama} + case "edgex": + return []string{srcEdgexNative, srcDefillama} + case "aster": + return []string{srcAsterNative, srcDefillama} + case "vertex": + return []string{srcVertexNative, srcDefillama} + case "grvt": + return []string{srcGrvtNative, srcDefillama} + case "extended": + return []string{srcExtendedNative, srcDefillama} + case "aevo": + return []string{srcAevoNative, srcDefillama} + case "pacifica": + return []string{srcPacificaNative, srcDefillama} + case "ostium": + // Subgraph OI is authoritative (BASE*price computed from + // longOI/shortOI + lastTradePrice). DefiLlama as fallback. + return []string{srcOstiumNative, srcDefillama} + case "variational": + // Native REST blocked. DefiLlama is the only path. + return []string{srcDefillama} + } + case mFees30d: + switch venue { + case "vertex": + // DefiLlama vertex-perps returns null for fees, so the + // native archive (cumulative_taker_fees + cumulative_maker_fees + // diffed across 31 daily granules) is primary. + return []string{srcVertexNative, srcDefillama} + } + return []string{srcDefillama} + case mActiveMarkets: + switch venue { + case "hyperliquid": + return []string{srcHLNative} + case "lighter": + // Lighter native is the authoritative count: it filters + // market_type=="perp" + status=="active". Mobula pairs + // counts the whole catalog and diverges constantly here, + // so we keep it for `gains` (where Lighter does not + // publish) but drop it from the lighter priority list. + return []string{srcLighterNative} + case "gains": + return []string{srcMobulaPairs} + case "gmx-v2": + return []string{srcDefillama} + case "dydx": + return []string{srcDydxNative} + case "paradex": + return []string{srcParadexNative} + case "edgex": + return []string{srcEdgexNative} + case "aster": + return []string{srcAsterNative} + case "vertex": + return []string{srcVertexNative} + case "grvt": + return []string{srcGrvtNative} + case "extended": + return []string{srcExtendedNative} + case "aevo": + return []string{srcAevoNative} + case "pacifica": + return []string{srcPacificaNative} + case "ostium": + return []string{srcOstiumNative} + case "variational": + // No native source can resolve active_markets without + // hitting the same blocked APIs. Leave unset; carry- + // forward will keep the gauge empty until a source ships. + return nil + } + case mTopVol24h: + switch venue { + case "hyperliquid": + return []string{srcHLNative} + case "lighter": + return []string{srcLighterNative} + case "dydx": + return []string{srcDydxNative} + case "paradex": + return []string{srcParadexNative} + case "edgex": + return []string{srcEdgexNative} + case "aster": + return []string{srcAsterNative} + case "vertex": + return []string{srcVertexNative} + case "grvt": + return []string{srcGrvtNative} + case "extended": + return []string{srcExtendedNative} + case "aevo": + return []string{srcAevoNative} + case "pacifica": + return []string{srcPacificaNative} + case "variational", "ostium": + // No top-market signal: Variational native is blocked, and + // the Ostium subgraph has no rolling 24h volume column. + return nil + } + } + return nil +} + +// Router orchestrates one sweep across all registered sources. +type Router struct { + cfg *Config + sources []Source + // carry holds the last successfully published value per (venue, + // metric). On a total-miss tick we re-emit the previous value but + // do NOT advance perp_venue_last_refresh_unix, so the UI can + // compute true data age. + carry map[string]map[string]carryEntry + carryMu sync.Mutex + fundCarry map[string]map[string]fundingCarryEntry + fundCarryM sync.Mutex +} + +type carryEntry struct { + Value float64 + RefreshTS int64 + LastSource string +} + +type fundingCarryEntry struct { + Bps24h float64 + IntervalHours float64 + RefreshTS int64 +} + +func NewRouter(cfg *Config) *Router { + sources := []Source{ + NewHyperliquidNativeSource(), + NewLighterNativeSource(), + NewDydxNativeSource(), + NewParadexNativeSource(), + NewEdgexNativeSource(), + NewAsterNativeSource(), + NewVertexNativeSource(), + NewGrvtNativeSource(), + NewExtendedNativeSource(), + NewAevoNativeSource(), + NewPacificaNativeSource(), + NewVariationalNativeSource(), + NewOstiumNativeSource(), + NewDefillamaScrapeSource(), + } + if cfg.MobulaAPIKey != "" { + sources = append(sources, + NewMobulaFundingSource(cfg.MobulaAPIKey, cfg.MobulaFundingVenues), + NewMobulaPairsSource(cfg.MobulaAPIKey), + ) + } else { + fmt.Println("[router] Mobula sources disabled (MOBULA_API_KEY unset)") + } + return &Router{ + cfg: cfg, + sources: sources, + carry: map[string]map[string]carryEntry{}, + fundCarry: map[string]map[string]fundingCarryEntry{}, + } +} + +func (r *Router) carryGet(venue, metric string) (carryEntry, bool) { + r.carryMu.Lock() + defer r.carryMu.Unlock() + if r.carry[venue] == nil { + return carryEntry{}, false + } + e, ok := r.carry[venue][metric] + return e, ok +} + +func (r *Router) carrySet(venue, metric string, v float64, source string, ts int64) { + r.carryMu.Lock() + defer r.carryMu.Unlock() + if r.carry[venue] == nil { + r.carry[venue] = map[string]carryEntry{} + } + r.carry[venue][metric] = carryEntry{Value: v, LastSource: source, RefreshTS: ts} +} + +func (r *Router) fundingCarryGet(venue, asset string) (fundingCarryEntry, bool) { + r.fundCarryM.Lock() + defer r.fundCarryM.Unlock() + if r.fundCarry[venue] == nil { + return fundingCarryEntry{}, false + } + e, ok := r.fundCarry[venue][asset] + return e, ok +} + +func (r *Router) fundingCarrySet(venue, asset string, p fundingPoint, ts int64) { + r.fundCarryM.Lock() + defer r.fundCarryM.Unlock() + if r.fundCarry[venue] == nil { + r.fundCarry[venue] = map[string]fundingCarryEntry{} + } + r.fundCarry[venue][asset] = fundingCarryEntry{ + Bps24h: p.Bps24h, + IntervalHours: p.IntervalHours, + RefreshTS: ts, + } +} + +// Sweep runs every registered source, picks a winner per (venue, metric) +// from the priority map, runs the cross-check on the secondary, publishes +// gauges, and carries forward any (venue, metric) that fully missed. +func (r *Router) Sweep() { + tickTS := time.Now().Unix() + defer perpCohortLastTickUnix.Set(float64(tickTS)) + + // Fan-out: each source runs in its own goroutine with a 15s upper + // bound enforced inside the HTTP client. We collect per-source + // results and process them serially for deterministic precedence. + type fetched struct { + src Source + result *SourceResult + err error + } + results := make([]fetched, len(r.sources)) + var wg sync.WaitGroup + for i, s := range r.sources { + wg.Add(1) + go func(i int, s Source) { + defer wg.Done() + res, err := s.Fetch() + results[i] = fetched{src: s, result: res, err: err} + }(i, s) + } + wg.Wait() + + // Build a lookup of source-name -> SourceResult for the priority + // router. nil results are kept so we can tell "ran and failed" from + // "not registered". + byName := map[string]*SourceResult{} + for _, f := range results { + name := f.src.Name() + if f.err != nil { + fmt.Printf("[perp-cohort][_][%s] err: %v\n", name, f.err) + // Per-venue error attribution is done at the source level + // already (sources increment perpCohortFetchErrors with the + // correct venue label before returning). The wrapper error + // here is logged only. + continue + } + byName[name] = f.result + } + + // Resolve each (venue, metric) using priorityMap. Run cross-check + // against the next source in the priority list. + cohortMetrics := []string{mVolume24h, mVolume30d, mOI, mFees30d, mActiveMarkets, mTopVol24h} + for _, v := range Registry { + var healthHits, healthTotal int + for _, metric := range cohortMetrics { + prio := priorityMap(v.Slug, metric) + if len(prio) == 0 { + continue + } + healthTotal++ + + var winner string + var winnerVal float64 + var winnerFound bool + var secondVal float64 + var secondFound bool + for _, name := range prio { + res := byName[name] + if res == nil { + continue + } + val, ok := lookup(res, v.Slug, metric) + if !ok { + continue + } + if !winnerFound { + winner = name + winnerVal = val + winnerFound = true + continue + } + if !secondFound { + secondVal = val + secondFound = true + } + } + + if winnerFound { + healthHits++ + publishCohort(v.Slug, metric, winnerVal) + perpVenueLastRefreshUnix.WithLabelValues(v.Slug, winner).Set(float64(tickTS)) + // Reset source-used label set for this (venue, metric) + // then set winner=1. Reset uses a small known label set. + for _, name := range prio { + perpCohortSourceUsed.WithLabelValues(v.Slug, metric, name).Set(0) + } + perpCohortSourceUsed.WithLabelValues(v.Slug, metric, winner).Set(1) + r.carrySet(v.Slug, metric, winnerVal, winner, tickTS) + + if secondFound { + if divergent(winnerVal, secondVal, 0.10) { + perpCohortDataDivergence.WithLabelValues(v.Slug, metric).Inc() + } + } + } else { + // Carry-forward: republish the last known value if we + // have one, but do NOT advance the refresh timestamp. + if e, ok := r.carryGet(v.Slug, metric); ok { + publishCohort(v.Slug, metric, e.Value) + } + } + } + + // Health: fraction of metrics with primary-source success this tick. + if healthTotal > 0 { + perpVenueHealth.WithLabelValues(v.Slug).Set(float64(healthHits) / float64(healthTotal)) + } + } + + // Funding is a separate publish path because it is keyed by (venue, + // asset), not just venue. The Mobula source is currently the only + // funding source; carry-forward applies per (venue, asset). + // + // We publish funding for EVERY venue Mobula returns (12 CEFI+DEX + // venues in the default cohort), not just the OCB perp-DEX + // Registry. The funding gauge surface is used by adjacent UI + // panels (CEFI vs DEX funding spread, cross-venue funding + // arbitrage), so the broader cardinality is intentional. + mobula := byName[srcMobulaFund] + if mobula != nil { + for venueSlug, perAsset := range mobula.Funding { + for asset, p := range perAsset { + perpVenueFunding24hBps.WithLabelValues(venueSlug, asset).Set(p.Bps24h) + perpVenueFundingIntervalHours.WithLabelValues(venueSlug, asset).Set(p.IntervalHours) + perpVenueLastRefreshUnix.WithLabelValues(venueSlug, srcMobulaFund).Set(float64(tickTS)) + r.fundingCarrySet(venueSlug, asset, p, tickTS) + } + } + } + // Carry-forward for any (venue, asset) we previously had but did + // not refresh this tick. + r.fundCarryM.Lock() + for venueSlug, perAsset := range r.fundCarry { + for asset, e := range perAsset { + if e.RefreshTS == tickTS { + continue + } + perpVenueFunding24hBps.WithLabelValues(venueSlug, asset).Set(e.Bps24h) + perpVenueFundingIntervalHours.WithLabelValues(venueSlug, asset).Set(e.IntervalHours) + } + } + r.fundCarryM.Unlock() + + // Reap stale carry entries so renamed/removed venues do not leak + // memory and never republish ghost values forever. Cohort metrics + // are pruned against the canonical Registry (rename = remove from + // registry + re-add under new slug = old slug evicted). Funding + // entries are pruned against a 24h max-age window so CEFI venues + // Mobula stopped covering also drop out. + r.reapCohortCarry() + r.reapFundingCarry(tickTS, 24*60*60) +} + +// reapCohortCarry drops any venue from the cohort carry map that is +// no longer in Registry. Safe to call after every sweep; cheap. +func (r *Router) reapCohortCarry() { + r.carryMu.Lock() + defer r.carryMu.Unlock() + keep := map[string]struct{}{} + for _, v := range Registry { + keep[v.Slug] = struct{}{} + } + for venue := range r.carry { + if _, ok := keep[venue]; !ok { + delete(r.carry, venue) + } + } +} + +// reapFundingCarry drops any (venue, asset) carry entry older than +// maxAgeSec. The funding cardinality is broader than Registry (CEFI +// venues like binance, bybit are included on purpose for adjacent UI +// panels), so we cannot prune against Registry. Age is the next best +// signal: if Mobula stopped publishing a venue for 24h it should fall +// off the gauge. +func (r *Router) reapFundingCarry(nowTS int64, maxAgeSec int64) { + r.fundCarryM.Lock() + defer r.fundCarryM.Unlock() + for venue, perAsset := range r.fundCarry { + for asset, e := range perAsset { + if nowTS-e.RefreshTS > maxAgeSec { + delete(perAsset, asset) + } + } + if len(perAsset) == 0 { + delete(r.fundCarry, venue) + } + } +} + +func lookup(r *SourceResult, venue, metric string) (float64, bool) { + if r == nil || r.Values[venue] == nil { + return 0, false + } + v, ok := r.Values[venue][metric] + return v, ok +} + +// publishCohort maps the internal metric key to the Prom gauge. +func publishCohort(venue, metric string, val float64) { + switch metric { + case mVolume24h: + perpVenueVolume24hUsd.WithLabelValues(venue).Set(val) + case mVolume30d: + perpVenueVolume30dUsd.WithLabelValues(venue).Set(val) + case mOI: + perpVenueOIUsd.WithLabelValues(venue).Set(val) + case mFees30d: + perpVenueFees30dUsd.WithLabelValues(venue).Set(val) + case mActiveMarkets: + perpVenueActiveMarkets.WithLabelValues(venue).Set(val) + case mTopVol24h: + perpVenueTopMarketVolume24hUsd.WithLabelValues(venue).Set(val) + } +} + +// divergent returns true if |a-b|/max(a,b) > threshold. +// Returns false if either value is non-positive (cross-check skipped +// when one side is zero/missing). +func divergent(a, b, threshold float64) bool { + if a <= 0 || b <= 0 { + return false + } + m := math.Max(a, b) + if m == 0 { + return false + } + return math.Abs(a-b)/m > threshold +} diff --git a/harnesses/perp-cohort-stats/cmd/script/config.go b/harnesses/perp-cohort-stats/cmd/script/config.go new file mode 100644 index 00000000..faa93734 --- /dev/null +++ b/harnesses/perp-cohort-stats/cmd/script/config.go @@ -0,0 +1,77 @@ +package main + +import ( + "fmt" + "os" + "strconv" + "time" +) + +// Config holds runtime knobs for the perp-cohort-stats harness. +// +// The harness is intentionally single-knob (one tick interval) because +// every per-venue cohort metric is published on the same cadence. The +// per-source HTTP timeout is fixed at 15s in adapter.go to keep the +// sweep wall-clock bounded. +type Config struct { + // TickInterval is how often the full source sweep runs. Default 60s. + // Override with TICK_INTERVAL_SECONDS. The per-tick request load is: + // - 3 x Mobula funding (one per asset BTC/ETH/SOL) + // - 1 x Hyperliquid info POST + // - 2 x Lighter native (exchangeStats + orderBookDetails) + // - 3 x DefiLlama HTML (one per venue) + // ~= 9 requests/tick across 4 hosts, comfortably polite. + TickInterval time.Duration + + // MobulaAPIKey is the Authorization header value for api.mobula.io. + // Must be supplied via the MOBULA_API_KEY env var. When empty, the + // two Mobula-backed sources are skipped entirely (the rest of the + // cohort sweep keeps running on native + DefiLlama sources). + MobulaAPIKey string + + // MobulaFundingVenues is the comma-separated `exchange` query value + // sent to /api/1/market/cefi/funding-rate. The default covers the + // 12-venue OCB perp cohort. Each call returns one row per venue per + // asset, so 3 asset calls cover the full BTC/ETH/SOL/12-venue grid. + MobulaFundingVenues string +} + +func loadConfig() *Config { + c := &Config{ + TickInterval: 60 * time.Second, + MobulaAPIKey: "", + MobulaFundingVenues: "binance,bybit,okx,hyperliquid,gate,lighter,kucoin,mexc,bitget,kraken,coinbase,deribit", + } + + if v := os.Getenv("TICK_INTERVAL_SECONDS"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + c.TickInterval = time.Duration(n) * time.Second + } + } + if v := os.Getenv("MOBULA_API_KEY"); v != "" { + c.MobulaAPIKey = v + } + if v := os.Getenv("MOBULA_FUNDING_VENUES"); v != "" { + c.MobulaFundingVenues = v + } + + if c.MobulaAPIKey == "" { + fmt.Println("[config] WARN: MOBULA_API_KEY is unset; Mobula funding + Mobula pairs sources will be skipped this run.") + } + + fmt.Printf("Config: venues=%d, tick=%v, mobula_venues=%q, mobula_key=%s\n", + len(Registry), c.TickInterval, c.MobulaFundingVenues, maskedKey(c.MobulaAPIKey)) + return c +} + +// maskedKey renders the Mobula key as e.g. "cb08****d689" so the boot +// log proves the key is loaded without leaking the value to stdout. +func maskedKey(k string) string { + if k == "" { + return "" + } + if len(k) < 8 { + return "****" + } + return k[:4] + "****" + k[len(k)-4:] +} diff --git a/harnesses/perp-cohort-stats/cmd/script/crosscheck.go b/harnesses/perp-cohort-stats/cmd/script/crosscheck.go new file mode 100644 index 00000000..3a9394d6 --- /dev/null +++ b/harnesses/perp-cohort-stats/cmd/script/crosscheck.go @@ -0,0 +1,17 @@ +package main + +// crosscheck.go is intentionally minimal: the divergence comparison is +// folded into Router.Sweep in adapter.go so we can use the same +// per-source results without re-running fetches. This file exists as +// a placeholder so future divergence policy (per-metric thresholds, +// rolling-window smoothing) has an obvious home. +// +// Today's policy: +// - threshold 10 percent (|a-b|/max(a,b) > 0.10) increments +// perp_cohort_stats_data_divergence_total{venue, metric} +// - cross-check fires only when BOTH primary and secondary sources +// returned non-zero values; "secondary missing" is not a divergence, +// it is just a single-source publication. +// - divergence is a counter, not a publishing gate: the primary +// winner is published regardless, the counter just flags the case +// so the UI / dashboards can surface "two sources disagree". diff --git a/harnesses/perp-cohort-stats/cmd/script/loghub.go b/harnesses/perp-cohort-stats/cmd/script/loghub.go new file mode 100644 index 00000000..bc7b4169 --- /dev/null +++ b/harnesses/perp-cohort-stats/cmd/script/loghub.go @@ -0,0 +1,119 @@ +package main + +import ( + "bufio" + "crypto/subtle" + "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 + } + // Constant-time compare: avoids leaking token bytes via response + // timing if an attacker can measure us. Equal-length is enforced + // by subtle.ConstantTimeCompare returning 0 on length mismatch. + got := r.Header.Get("X-Logs-Token") + if subtle.ConstantTimeCompare([]byte(got), []byte(expected)) != 1 { + 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/perp-cohort-stats/cmd/script/main.go b/harnesses/perp-cohort-stats/cmd/script/main.go new file mode 100644 index 00000000..df42f1b4 --- /dev/null +++ b/harnesses/perp-cohort-stats/cmd/script/main.go @@ -0,0 +1,92 @@ +// perp-cohort-stats is a Prom-exporter harness that publishes per-venue +// cohort gauges for the OCB perp benches and product pages. +// +// === Gauges exposed ============================================== +// perp_venue_volume_24h_usd{venue} +// perp_venue_volume_30d_usd{venue} +// perp_venue_oi_usd{venue} +// perp_venue_fees_30d_usd{venue} +// perp_venue_active_markets{venue} +// perp_venue_top_market_volume_24h_usd{venue} +// perp_venue_health{venue} +// perp_venue_funding_24h_bps{venue, asset} +// perp_venue_funding_interval_hours{venue, asset} +// perp_venue_last_refresh_unix{venue, source} +// perp_cohort_stats_source_used{venue, metric, source} +// perp_cohort_stats_fetch_errors_total{venue, source, error_type} +// perp_cohort_stats_data_divergence_total{venue, metric} +// perp_cohort_stats_last_tick_unix +// +// Architecture is multi-source with a priority router (see adapter.go). +// Each metric has an ordered list of acceptable sources; the first that +// returns non-nil wins. The next source in the list is still consulted +// for cross-check: divergences > 10 percent bump a counter but never +// gate publication. +// +// On full miss (every source for a (venue, metric) failed), the router +// republishes the last in-memory value but does NOT advance the +// per-source last_refresh_unix gauge, so the UI can compute true age. +// +// HTTP server is fixed at :2112 per the OCB Railway harness convention. +package main + +import ( + "fmt" + "os" + "os/signal" + "sync" + "syscall" + "time" +) + +func main() { + installLogCapture() + fmt.Println("=== perp-cohort-stats harness ===") + fmt.Println("Multi-source per-venue perp cohort stats (HL native, Lighter native, DefiLlama, Mobula funding + pairs).") + fmt.Println("Exposes /metrics, /health, /logs on :2112.") + + cfg := loadConfig() + router := NewRouter(cfg) + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + + var wg sync.WaitGroup + stop := make(chan struct{}) + + 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) + } + }() + + wg.Add(1) + go func() { + defer wg.Done() + runSweepLoop(cfg, router, stop) + }() + + <-sigChan + fmt.Println("\nShutting down...") + close(stop) + wg.Wait() +} + +func runSweepLoop(cfg *Config, router *Router, stop <-chan struct{}) { + tick := time.NewTicker(cfg.TickInterval) + defer tick.Stop() + + // Run once immediately so /metrics has values before the first tick. + router.Sweep() + for { + select { + case <-stop: + return + case <-tick.C: + router.Sweep() + } + } +} diff --git a/harnesses/perp-cohort-stats/cmd/script/metrics.go b/harnesses/perp-cohort-stats/cmd/script/metrics.go new file mode 100644 index 00000000..c24f7eaf --- /dev/null +++ b/harnesses/perp-cohort-stats/cmd/script/metrics.go @@ -0,0 +1,180 @@ +package main + +import ( + "net/http" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +// All gauges below are read by the OCB UI side via the exact Prom +// selectors documented in the perp-cohort-stats spec. Renaming any of +// these breaks the contract; if a metric needs to evolve, add a new +// gauge and dual-publish until the UI cut-over lands. +// +// Naming convention is `perp_venue_` for per-venue cohort +// gauges; observability gauges/counters live under +// `perp_cohort_stats_*`. +var ( + // Cohort gauges ==================================================== + perpVenueVolume24hUsd = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "perp_venue_volume_24h_usd", + Help: "Notional traded volume in USD over the last 24h, per perp venue. Source: HL native (sum dayNtlVlm), Lighter native (total_quote_token_volume_24h), DefiLlama HTML perpVolume.total24h.", + }, + []string{"venue"}, + ) + perpVenueVolume30dUsd = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "perp_venue_volume_30d_usd", + Help: "Notional traded volume in USD over the last 30d, per perp venue. Source: DefiLlama HTML perpVolume.total30d (no native equivalent on HL/Lighter info endpoints).", + }, + []string{"venue"}, + ) + perpVenueOIUsd = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "perp_venue_oi_usd", + Help: "Current open interest in USD, per perp venue. Source: HL native (sum openInterest*markPx), Lighter native (sum open_interest across orderBookDetails markets), DefiLlama HTML openInterest.total24h.", + }, + []string{"venue"}, + ) + perpVenueFees30dUsd = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "perp_venue_fees_30d_usd", + Help: "Total protocol fees in USD over the last 30d, per perp venue. Source: DefiLlama HTML fees.total30d (DefiLlama is the canonical aggregator across perp DEXes).", + }, + []string{"venue"}, + ) + perpVenueActiveMarkets = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "perp_venue_active_markets", + Help: "Number of perp markets actively trading, per venue. Source: HL native (len(ctxs)), Lighter native (len(orderBookDetails)), Mobula pairs (cross-check). For GMX, derived from DefiLlama protocol breakdown.", + }, + []string{"venue"}, + ) + perpVenueTopMarketVolume24hUsd = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "perp_venue_top_market_volume_24h_usd", + Help: "Highest single-market 24h volume in USD, per perp venue. Source: HL native (max dayNtlVlm), Lighter native (max daily_quote_token_volume).", + }, + []string{"venue"}, + ) + perpVenueHealth = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "perp_venue_health", + Help: "Composite health score [0..1] per venue. 1 = all primary sources healthy, fractional = some sources failed but carry-forward kept gauges populated, 0 = total miss.", + }, + []string{"venue"}, + ) + perpVenueFunding24hBps = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "perp_venue_funding_24h_bps", + Help: "Funding rate per venue per asset, normalized to a 24h figure in basis points. Source: Mobula /market/cefi/funding-rate. Formula: fundingRate * (24h_ms / epochDurationMs) * 10000.", + }, + []string{"venue", "asset"}, + ) + perpVenueFundingIntervalHours = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "perp_venue_funding_interval_hours", + Help: "Funding interval in hours per venue per asset. Source: Mobula /market/cefi/funding-rate epochDurationMs / 3600000.", + }, + []string{"venue", "asset"}, + ) + perpVenueLastRefreshUnix = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "perp_venue_last_refresh_unix", + Help: "Unix timestamp of the last successful refresh for a (venue, source) pair. On total-miss ticks the carry-forward keeps the gauge value but does NOT advance this timestamp, so the UI can compute true data age.", + }, + []string{"venue", "source"}, + ) + + // Observability ==================================================== + perpCohortFetchErrors = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "perp_cohort_stats_fetch_errors_total", + Help: "Total number of fetch failures per (venue, source, error_type). error_type is the bounded classifyError() bucket.", + }, + []string{"venue", "source", "error_type"}, + ) + perpCohortSourceUsed = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "perp_cohort_stats_source_used", + Help: "Set to 1 when the named source served the latest value for the (venue, metric). Resets each tick so a venue×metric has exactly one 1 across the source label.", + }, + []string{"venue", "metric", "source"}, + ) + perpCohortDataDivergence = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "perp_cohort_stats_data_divergence_total", + Help: "Incremented when two sources disagree by more than 10 percent (|a-b|/max(a,b) > 0.10) for the same (venue, metric).", + }, + []string{"venue", "metric"}, + ) + perpCohortLastTickUnix = prometheus.NewGauge( + prometheus.GaugeOpts{ + Name: "perp_cohort_stats_last_tick_unix", + Help: "Unix timestamp of the last harness sweep. Liveness probe.", + }, + ) +) + +func init() { + prometheus.MustRegister( + perpVenueVolume24hUsd, perpVenueVolume30dUsd, perpVenueOIUsd, + perpVenueFees30dUsd, perpVenueActiveMarkets, perpVenueTopMarketVolume24hUsd, + perpVenueHealth, perpVenueFunding24hBps, perpVenueFundingIntervalHours, + perpVenueLastRefreshUnix, + perpCohortFetchErrors, perpCohortSourceUsed, perpCohortDataDivergence, + perpCohortLastTickUnix, + ) +} + +// classifyError buckets an error message into a small finite enum so +// the fetch-errors counter stays bounded in cardinality. Mirrors the +// pm-cohort-stats classifier so OCB dashboards can reuse one template. +func classifyError(msg string) string { + switch { + case contains(msg, "timeout"), contains(msg, "deadline"): + return "timeout" + case contains(msg, "401"), contains(msg, "403"), contains(msg, "unauthorized"): + return "auth" + case contains(msg, "429"): + return "rate_limit" + case contains(msg, "500"), contains(msg, "502"), contains(msg, "503"), contains(msg, "504"): + return "server_error" + case contains(msg, "404"): + return "not_found" + case contains(msg, "not_tracked"), contains(msg, "empty_series"), contains(msg, "unavailable"): + return "not_tracked" + case contains(msg, "parse"): + return "parse" + default: + return "other" + } +} + +func contains(s, sub string) bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} + +// truncate caps a string at n bytes; used to keep error messages compact +// when they get echoed into Prom error counters via classifyError. +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] +} + +func StartMetricsServer(addr string) error { + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.Handler()) + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("OK")) }) + mux.Handle("/logs", logsHandler()) + return http.ListenAndServe(addr, mux) +} diff --git a/harnesses/perp-cohort-stats/cmd/script/registry.go b/harnesses/perp-cohort-stats/cmd/script/registry.go new file mode 100644 index 00000000..40738cbc --- /dev/null +++ b/harnesses/perp-cohort-stats/cmd/script/registry.go @@ -0,0 +1,54 @@ +package main + +// Venue is one OCB-tracked perp DEX with its routing tags. The Slug +// field MUST match the OCB site's perp venue registry so the Prom +// selector `{venue=""}` matches what the bench page reads. +// +// Adding a venue: +// 1. Append here +// 2. Append on the OCB site's perp registry +// 3. Add priority entries in adapter.go priorityMap() +// 4. Redeploy both sides +type Venue struct { + Slug string + Name string + Type string // "perp" + Chain string // settlement chain (hyperliquid, base, arbitrum, ...) +} + +// Registry is the canonical list of OCB-tracked perp venues. Order = +// display order in the perp hub. The framework scales by appending. +var Registry = []Venue{ + {Slug: "hyperliquid", Name: "Hyperliquid", Type: "perp", Chain: "hyperliquid"}, + {Slug: "lighter", Name: "Lighter", Type: "perp", Chain: "zksync"}, + {Slug: "gmx-v2", Name: "GMX V2", Type: "perp", Chain: "arbitrum"}, + {Slug: "gains", Name: "Gains Network", Type: "perp", Chain: "arbitrum"}, + {Slug: "dydx", Name: "dYdX v4", Type: "perp", Chain: "dydx"}, + {Slug: "paradex", Name: "Paradex", Type: "perp", Chain: "paradex"}, + {Slug: "edgex", Name: "edgeX", Type: "perp", Chain: "edgex"}, + {Slug: "aster", Name: "Aster", Type: "perp", Chain: "bnb"}, + {Slug: "vertex", Name: "Vertex", Type: "perp", Chain: "arbitrum"}, + {Slug: "grvt", Name: "GRVT", Type: "perp", Chain: "grvt"}, + // TODO(sprint4): re-add Drift once the Solana RPC + Anchor IDL + // adapter ships. The public REST surface (dlob, mainnet-beta, api, + // data.api) has been fully blocked for the entire Sprint 3 audit + // and DefiLlama's drift page reports total24h=$0 because the + // upstream adapter is broken too, so the venue was visually present + // on /perps but always rendered as a dashed-out row that 404'd on + // click. Park the entry here instead of shipping a stub. + {Slug: "extended", Name: "Extended", Type: "perp", Chain: "starknet"}, + {Slug: "aevo", Name: "Aevo", Type: "perp", Chain: "aevo"}, + {Slug: "pacifica", Name: "Pacifica", Type: "perp", Chain: "solana"}, + {Slug: "variational", Name: "Variational", Type: "perp", Chain: "arbitrum"}, + {Slug: "ostium", Name: "Ostium", Type: "perp", Chain: "arbitrum"}, +} + +// VenueBySlug returns the Venue with the given slug, or nil if not found. +func VenueBySlug(slug string) *Venue { + for i := range Registry { + if Registry[i].Slug == slug { + return &Registry[i] + } + } + return nil +} diff --git a/harnesses/perp-cohort-stats/cmd/script/source_aevo.go b/harnesses/perp-cohort-stats/cmd/script/source_aevo.go new file mode 100644 index 00000000..81828daf --- /dev/null +++ b/harnesses/perp-cohort-stats/cmd/script/source_aevo.go @@ -0,0 +1,108 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "time" +) + +// AevoNativeSource hits the CoinGecko-compatible aggregate endpoint: +// +// GET https://api.aevo.xyz/coingecko-statistics?type=PERPETUAL +// +// The response is a flat array, one row per perp instrument +// (~210 markets). Each row exposes: +// +// ticker_id (e.g. "BTC-PERP") +// target_volume (USD notional 24h, string; matches the per-asset +// statistics.daily_volume field exactly) +// open_interest (BASE units, string) +// index_price (USD, string) +// +// Derived metrics: +// +// volume_24h_usd = sum(target_volume) +// oi_usd = sum(open_interest * index_price) +// active_markets = count(rows with index_price > 0) +// top_market_volume_24h_usd = max(target_volume) +type AevoNativeSource struct { + client *http.Client +} + +func NewAevoNativeSource() *AevoNativeSource { + return &AevoNativeSource{ + client: &http.Client{Timeout: 20 * time.Second}, + } +} + +func (s *AevoNativeSource) Name() string { return srcAevoNative } + +type aevoRow struct { + TickerID string `json:"ticker_id"` + TargetVolume string `json:"target_volume"` + OpenInterest string `json:"open_interest"` + IndexPrice string `json:"index_price"` +} + +func (s *AevoNativeSource) Fetch() (*SourceResult, error) { + res := newSourceResult() + venue := "aevo" + + body, err := s.get("https://api.aevo.xyz/coingecko-statistics?type=PERPETUAL") + if err != nil { + perpCohortFetchErrors.WithLabelValues(venue, srcAevoNative, classifyError(err.Error())).Inc() + fmt.Printf("[perp-cohort][%s][%s] err: %v\n", venue, srcAevoNative, err) + return res, nil + } + + var rows []aevoRow + if err := json.Unmarshal(body, &rows); err != nil { + perpCohortFetchErrors.WithLabelValues(venue, srcAevoNative, "parse").Inc() + fmt.Printf("[perp-cohort][%s][%s] err parse: %v\n", venue, srcAevoNative, err) + return res, nil + } + + var volSum, oiSum, topVol float64 + var active int + for _, r := range rows { + px, _ := strconv.ParseFloat(r.IndexPrice, 64) + if px <= 0 { + continue + } + active++ + v, _ := strconv.ParseFloat(r.TargetVolume, 64) + volSum += v + if v > topVol { + topVol = v + } + oiBase, _ := strconv.ParseFloat(r.OpenInterest, 64) + oiSum += oiBase * px + } + + res.SetIfPositive(venue, mVolume24h, volSum) + res.SetIfPositive(venue, mOI, oiSum) + res.SetIfPositive(venue, mActiveMarkets, float64(active)) + res.SetIfPositive(venue, mTopVol24h, topVol) + fmt.Printf("[perp-cohort][%s][%s] ok: active=%d vol24h=%.0f oi=%.0f top24h=%.0f\n", + venue, srcAevoNative, active, volSum, oiSum, topVol) + return res, nil +} + +func (s *AevoNativeSource) get(url string) ([]byte, error) { + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("User-Agent", "OpenChainBench-PerpCohort/1.0 contact@mobula.io") + req.Header.Set("Accept", "application/json") + resp, err := s.client.Do(req) + if err != nil { + return nil, fmt.Errorf("request_error: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return nil, fmt.Errorf("status_%d: %s", resp.StatusCode, truncate(string(body), 200)) + } + return body, nil +} diff --git a/harnesses/perp-cohort-stats/cmd/script/source_aster.go b/harnesses/perp-cohort-stats/cmd/script/source_aster.go new file mode 100644 index 00000000..31bfba99 --- /dev/null +++ b/harnesses/perp-cohort-stats/cmd/script/source_aster.go @@ -0,0 +1,211 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "sync" + "time" +) + +// AsterNativeSource hits the asterdex.com futures gateway: +// +// GET https://fapi.asterdex.com/fapi/v1/ticker/24hr (bulk volume) +// GET https://fapi.asterdex.com/fapi/v1/openInterest?symbol=X (per symbol) +// +// Volume + count come from a single ticker call that returns one row +// per symbol (~495 rows). OI is per-symbol only; we throttle the loop +// to 10 req/s and cache the resulting OI map for 30s so the inner +// 60s tick reuses it once. With ~495 symbols a fresh OI sweep takes +// ~50s, just inside the 60s tick budget. +// +// Symbols ending in `USDT` are linear perp pairs; the small set of +// USDC-quoted pairs is included via the same suffix filter +// (`USDT`||`USDC`). +// +// Derived metrics: +// +// volume_24h_usd = sum(quoteVolume) +// oi_usd = sum(openInterest_base * lastPrice) +// active_markets = count(symbols with priceChangePercent != 0) +// top_market_volume_24h_usd = max(quoteVolume) +type AsterNativeSource struct { + client *http.Client + oiMu sync.Mutex + oiCache map[string]float64 + oiTS time.Time + oiRefreshing bool +} + +func NewAsterNativeSource() *AsterNativeSource { + return &AsterNativeSource{ + client: &http.Client{Timeout: 15 * time.Second}, + oiCache: map[string]float64{}, + } +} + +func (s *AsterNativeSource) Name() string { return srcAsterNative } + +type asterTicker struct { + Symbol string `json:"symbol"` + LastPrice string `json:"lastPrice"` + PriceChangePercent string `json:"priceChangePercent"` + Volume string `json:"volume"` + QuoteVolume string `json:"quoteVolume"` +} + +type asterOIResponse struct { + Symbol string `json:"symbol"` + OpenInterest string `json:"openInterest"` + Time int64 `json:"time"` +} + +// asterOICacheTTL keeps the per-symbol OI map fresh enough for an +// OI gauge that updates ~12x/hour while keeping per-tick request load +// under control. A full OI sweep at 10 req/s over ~495 symbols takes +// ~50s; refreshing every 5 min means we burn ~1 minute of HTTP every +// 5 minutes (sustained ~1.6 req/s averaged) which is well under any +// reasonable public rate limit. +const asterOICacheTTL = 5 * time.Minute + +func (s *AsterNativeSource) Fetch() (*SourceResult, error) { + res := newSourceResult() + venue := "aster" + + // Step 1: bulk ticker for the whole symbol universe. + body, err := s.get("https://fapi.asterdex.com/fapi/v1/ticker/24hr") + if err != nil { + perpCohortFetchErrors.WithLabelValues(venue, srcAsterNative, classifyError(err.Error())).Inc() + fmt.Printf("[perp-cohort][%s][%s] err ticker: %v\n", venue, srcAsterNative, err) + return res, nil + } + var tickers []asterTicker + if err := json.Unmarshal(body, &tickers); err != nil { + perpCohortFetchErrors.WithLabelValues(venue, srcAsterNative, "parse").Inc() + fmt.Printf("[perp-cohort][%s][%s] err parse ticker: %v\n", venue, srcAsterNative, err) + return res, nil + } + + keep := make([]asterRow, 0, len(tickers)) + var volSum, topVol float64 + for _, t := range tickers { + // Linear perp pairs only. + if !strings.HasSuffix(t.Symbol, "USDT") && !strings.HasSuffix(t.Symbol, "USDC") { + continue + } + qv, _ := strconv.ParseFloat(t.QuoteVolume, 64) + last, _ := strconv.ParseFloat(t.LastPrice, 64) + volSum += qv + if qv > topVol { + topVol = qv + } + keep = append(keep, asterRow{symbol: t.Symbol, last: last, qvol: qv}) + } + + // Step 2: kick off async OI refresh if the cache is stale. The + // refresh runs in the background so a single sweep never blocks + // for the full ~50s OI loop; first-tick OI is 0 (Set is skipped), + // subsequent ticks see the cached map. + s.maybeRefreshOI(keep) + + // Step 3: aggregate OI in USD from the cached map. + s.oiMu.Lock() + var oiSum float64 + for _, r := range keep { + if r.last <= 0 { + continue + } + if oiBase, ok := s.oiCache[r.symbol]; ok { + oiSum += oiBase * r.last + } + } + cached := len(s.oiCache) + s.oiMu.Unlock() + + res.SetIfPositive(venue, mVolume24h, volSum) + res.SetIfPositive(venue, mOI, oiSum) + res.SetIfPositive(venue, mActiveMarkets, float64(len(keep))) + res.SetIfPositive(venue, mTopVol24h, topVol) + fmt.Printf("[perp-cohort][%s][%s] ok: markets=%d oi_cached=%d vol24h=%.0f oi=%.0f top24h=%.0f\n", + venue, srcAsterNative, len(keep), cached, volSum, oiSum, topVol) + return res, nil +} + +type asterRow struct { + symbol string + last float64 + qvol float64 +} + +// maybeRefreshOI schedules a background OI refresh if the cache is +// stale and no refresh is already running. The actual loop runs in a +// goroutine at 10 req/s; the calling Fetch() returns immediately with +// whatever map snapshot is currently cached. +func (s *AsterNativeSource) maybeRefreshOI(symbols []asterRow) { + s.oiMu.Lock() + if s.oiRefreshing { + s.oiMu.Unlock() + return + } + if time.Since(s.oiTS) < asterOICacheTTL && len(s.oiCache) > 0 { + s.oiMu.Unlock() + return + } + s.oiRefreshing = true + s.oiMu.Unlock() + + go s.runOIRefresh(symbols) +} + +func (s *AsterNativeSource) runOIRefresh(symbols []asterRow) { + defer func() { + s.oiMu.Lock() + s.oiRefreshing = false + s.oiMu.Unlock() + }() + + fresh := map[string]float64{} + tick := time.NewTicker(100 * time.Millisecond) + defer tick.Stop() + for _, r := range symbols { + <-tick.C + body, err := s.get(fmt.Sprintf( + "https://fapi.asterdex.com/fapi/v1/openInterest?symbol=%s", r.symbol)) + if err != nil { + continue + } + var oi asterOIResponse + if err := json.Unmarshal(body, &oi); err != nil { + continue + } + base, _ := strconv.ParseFloat(oi.OpenInterest, 64) + if base > 0 { + fresh[r.symbol] = base + } + } + + s.oiMu.Lock() + s.oiCache = fresh + s.oiTS = time.Now() + s.oiMu.Unlock() + fmt.Printf("[perp-cohort][aster][%s] oi cache refreshed: %d symbols\n", srcAsterNative, len(fresh)) +} + +func (s *AsterNativeSource) get(url string) ([]byte, error) { + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("User-Agent", "OpenChainBench-PerpCohort/1.0 contact@mobula.io") + req.Header.Set("Accept", "application/json") + resp, err := s.client.Do(req) + if err != nil { + return nil, fmt.Errorf("request_error: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return nil, fmt.Errorf("status_%d: %s", resp.StatusCode, truncate(string(body), 200)) + } + return body, nil +} diff --git a/harnesses/perp-cohort-stats/cmd/script/source_defillama.go b/harnesses/perp-cohort-stats/cmd/script/source_defillama.go new file mode 100644 index 00000000..5b426763 --- /dev/null +++ b/harnesses/perp-cohort-stats/cmd/script/source_defillama.go @@ -0,0 +1,248 @@ +package main + +import ( + "fmt" + "io" + "net/http" + "regexp" + "strconv" + "time" +) + +// DefiLlamaScrapeSource pulls cohort numbers off the public protocol +// HTML pages at https://defillama.com/protocol/. The free JSON +// summary endpoints (/summary/derivatives/, /summary/dexs/) +// now return 402 (paid plan) so HTML scraping is the only public path +// for perp volume + OI without an API subscription. +// +// The protocol HTML pages embed Next.js page-data as inline JSON. Each +// page contains structured blocks keyed by metric category: +// +// "perpVolume":{ "total24h":..., "total30d":... } +// "openInterest":{ "total24h":..., "total30d":... } +// "fees":{ "total24h":..., "total30d":... } +// +// We use a brace-matching extractor to pull the full nested block for +// each category, then scan for total24h / total30d. The pattern is +// stable across the 3 v1 venues; if DefiLlama changes its embed schema +// this source goes "unavailable" for that page and the router falls +// back to native sources (HL/Lighter) for the metrics they cover. +type DefiLlamaScrapeSource struct { + client *http.Client +} + +func NewDefillamaScrapeSource() *DefiLlamaScrapeSource { + return &DefiLlamaScrapeSource{ + client: &http.Client{Timeout: 15 * time.Second}, + } +} + +func (s *DefiLlamaScrapeSource) Name() string { return srcDefillama } + +// slugMap maps OCB venue slugs to DefiLlama protocol page slugs. +// Verified live against https://defillama.com/protocol/: +// - hyperliquid -> /protocol/hyperliquid (200 OK) +// - lighter -> /protocol/lighter (200 OK) +// - gmx-v2 -> /protocol/gmx-v2-perps (200 OK) +// - gains -> /protocol/gains-network (200 OK) +// - dydx -> /protocol/dydx-v4 (200 OK, perpVolume present) +// - paradex -> /protocol/paradex (200 OK, perpVolume present) +// - aster -> /protocol/aster (200 OK, perpVolume present) +// - edgex -> /protocol/edgex-perps (200 OK, perpVolume 24h=529M live) +// - grvt -> /protocol/grvt (200 OK, perpVolume present) +// +// Skipped: vertex (the /protocol/vertex-perps page has total24h=null +// today; native source is authoritative anyway). Re-add if DefiLlama +// resumes publishing daily numbers for the slug. +// +// Adding a venue: verify the page exists and returns the same embedded +// JSON blocks before adding the slug here. +var defillamaSlugMap = map[string]string{ + "hyperliquid": "hyperliquid", + "lighter": "lighter", + "gmx-v2": "gmx-v2-perps", + "gains": "gains-network", + "dydx": "dydx-v4", + "paradex": "paradex", + "aster": "aster", + "edgex": "edgex-perps", + "grvt": "grvt", + // Sprint 3: added after the 2026-06 probe sweep. Live perpVolume + // + openInterest verified on each page: + // - extended -> /protocol/extended (200 OK, vol24h live). + // - aevo -> /protocol/aevo (200 OK, vol24h live). + // - pacifica -> /protocol/pacifica (200 OK, vol24h live). + // - variational -> /protocol/variational (200 OK, vol24h live; + // api.variational.io + // is NXDOMAIN so this + // page is the only path). + // - ostium -> /protocol/ostium (200 OK, vol24h live). + // Drift is intentionally absent: its DefiLlama adapter reports + // $0 and the venue is parked from the Registry pending the + // Sprint 4 Solana RPC + Anchor IDL adapter. + "extended": "extended", + "aevo": "aevo", + "pacifica": "pacifica", + "variational": "variational", + "ostium": "ostium", +} + +func (s *DefiLlamaScrapeSource) Fetch() (*SourceResult, error) { + res := newSourceResult() + for venueSlug, llamaSlug := range defillamaSlugMap { + if err := s.fetchVenue(venueSlug, llamaSlug, res); err != nil { + perpCohortFetchErrors.WithLabelValues(venueSlug, srcDefillama, classifyError(err.Error())).Inc() + fmt.Printf("[perp-cohort][%s][%s] err: %v\n", venueSlug, srcDefillama, err) + continue + } + } + return res, nil +} + +func (s *DefiLlamaScrapeSource) fetchVenue(venueSlug, llamaSlug string, res *SourceResult) error { + url := fmt.Sprintf("https://defillama.com/protocol/%s", llamaSlug) + html, err := s.get(url) + if err != nil { + return err + } + + // Each block: {category}{nested-objects}{total24h,...,total30d,...} + // Drift-style broken feeds publish $0 on the dashboard; treat any + // extracted-but-zero perpVolume as "unavailable" so the source does + // not falsely win the priority race. + perpVol24, perpVol30, perpOK := extractTotals(html, "perpVolume") + oi24, _, oiOK := extractTotals(html, "openInterest") + _, fees30, feesOK := extractTotals(html, "fees") + + logParts := fmt.Sprintf("perp_ok=%v oi_ok=%v fees_ok=%v", perpOK, oiOK, feesOK) + if perpOK && perpVol24 > 0 { + res.Set(venueSlug, mVolume24h, perpVol24) + } + if perpOK && perpVol30 > 0 { + res.Set(venueSlug, mVolume30d, perpVol30) + } + if oiOK && oi24 > 0 { + res.Set(venueSlug, mOI, oi24) + } + if feesOK && fees30 > 0 { + res.Set(venueSlug, mFees30d, fees30) + } + + // If literally everything came back zero we treat the page as + // unavailable so the router falls back to other sources cleanly. + if !perpOK && !oiOK && !feesOK { + return fmt.Errorf("unavailable: no blocks extracted from /%s", llamaSlug) + } + + fmt.Printf("[perp-cohort][%s][%s] ok: %s vol24h=%.0f vol30d=%.0f oi=%.0f fees30d=%.0f\n", + venueSlug, srcDefillama, logParts, perpVol24, perpVol30, oi24, fees30) + return nil +} + +func (s *DefiLlamaScrapeSource) get(url string) ([]byte, error) { + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("User-Agent", "OpenChainBench-PerpCohort/1.0 contact@mobula.io") + req.Header.Set("Accept", "text/html,application/xhtml+xml") + resp, err := s.client.Do(req) + if err != nil { + return nil, fmt.Errorf("request_error: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return nil, fmt.Errorf("status_%d: %s", resp.StatusCode, truncate(string(body), 200)) + } + return body, nil +} + +// extractBlock returns the full JSON-object substring (including outer +// braces) for `"":{...}` using brace counting. Handles arbitrary +// nesting depth and skips quoted braces. Returns "" if not found. +func extractBlock(html []byte, key string) string { + needle := []byte(`"` + key + `":{`) + idx := indexOf(html, needle) + if idx < 0 { + return "" + } + start := idx + len(needle) - 1 // points at the '{' + depth := 0 + inStr := false + escape := false + for i := start; i < len(html); i++ { + c := html[i] + if escape { + escape = false + continue + } + if c == '\\' && inStr { + escape = true + continue + } + if c == '"' { + inStr = !inStr + continue + } + if inStr { + continue + } + switch c { + case '{': + depth++ + case '}': + depth-- + if depth == 0 { + return string(html[start : i+1]) + } + } + } + return "" +} + +var ( + reTotal24h = regexp.MustCompile(`"total24h":([0-9.]+)`) + reTotal30d = regexp.MustCompile(`"total30d":([0-9.]+)`) +) + +func extractTotals(html []byte, key string) (t24, t30 float64, ok bool) { + blk := extractBlock(html, key) + if blk == "" { + return 0, 0, false + } + m24 := reTotal24h.FindStringSubmatch(blk) + m30 := reTotal30d.FindStringSubmatch(blk) + if m24 != nil { + t24, _ = strconv.ParseFloat(m24[1], 64) + } + if m30 != nil { + t30, _ = strconv.ParseFloat(m30[1], 64) + } + // Consider a block "ok" if it parsed at all (even all-zero values + // from broken venues like Drift); the publisher above decides not + // to overwrite the gauge when the value is zero. + if m24 != nil || m30 != nil { + return t24, t30, true + } + return 0, 0, false +} + +// indexOf is a tiny byte-substring search; net/http already pulls in +// bytes, but a tight loop here avoids the import for one call. +func indexOf(hay, needle []byte) int { + if len(needle) == 0 { + return 0 + } + n := len(hay) - len(needle) + for i := 0; i <= n; i++ { + match := true + for j := 0; j < len(needle); j++ { + if hay[i+j] != needle[j] { + match = false + break + } + } + if match { + return i + } + } + return -1 +} diff --git a/harnesses/perp-cohort-stats/cmd/script/source_dydx.go b/harnesses/perp-cohort-stats/cmd/script/source_dydx.go new file mode 100644 index 00000000..173f004b --- /dev/null +++ b/harnesses/perp-cohort-stats/cmd/script/source_dydx.go @@ -0,0 +1,111 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "time" +) + +// DydxNativeSource calls the v4 indexer: +// +// GET https://indexer.dydx.trade/v4/perpetualMarkets +// +// Response shape: { "markets": { "BTC-USD": { ... }, "ETH-USD": {...}, ... } }. +// Each market exposes: +// +// status ("ACTIVE" / "FINAL_SETTLEMENT" / "PAUSED" / ...) +// volume24H (USD notional, string) +// openInterest (BASE units, string) +// oraclePrice (USD, string) +// +// Derived metrics: +// +// volume_24h_usd = sum(volume24H) over ACTIVE markets +// oi_usd = sum(openInterest * oraclePrice) over ACTIVE markets +// active_markets = count(status == "ACTIVE") +// top_market_volume_24h_usd = max(volume24H) over ACTIVE markets +type DydxNativeSource struct { + client *http.Client +} + +func NewDydxNativeSource() *DydxNativeSource { + return &DydxNativeSource{ + client: &http.Client{Timeout: 15 * time.Second}, + } +} + +func (s *DydxNativeSource) Name() string { return srcDydxNative } + +type dydxMarket struct { + Status string `json:"status"` + Volume24H string `json:"volume24H"` + OpenInterest string `json:"openInterest"` + OraclePrice string `json:"oraclePrice"` +} + +type dydxResponse struct { + Markets map[string]dydxMarket `json:"markets"` +} + +func (s *DydxNativeSource) Fetch() (*SourceResult, error) { + res := newSourceResult() + venue := "dydx" + + body, err := s.get("https://indexer.dydx.trade/v4/perpetualMarkets") + if err != nil { + perpCohortFetchErrors.WithLabelValues(venue, srcDydxNative, classifyError(err.Error())).Inc() + fmt.Printf("[perp-cohort][%s][%s] err: %v\n", venue, srcDydxNative, err) + return res, nil + } + + var parsed dydxResponse + if err := json.Unmarshal(body, &parsed); err != nil { + perpCohortFetchErrors.WithLabelValues(venue, srcDydxNative, "parse").Inc() + fmt.Printf("[perp-cohort][%s][%s] err parse: %v\n", venue, srcDydxNative, err) + return res, nil + } + + var volSum, oiSum, topVol float64 + var active int + for _, m := range parsed.Markets { + if m.Status != "ACTIVE" { + continue + } + active++ + v, _ := strconv.ParseFloat(m.Volume24H, 64) + volSum += v + if v > topVol { + topVol = v + } + oi, _ := strconv.ParseFloat(m.OpenInterest, 64) + px, _ := strconv.ParseFloat(m.OraclePrice, 64) + oiSum += oi * px + } + + res.SetIfPositive(venue, mVolume24h, volSum) + res.SetIfPositive(venue, mOI, oiSum) + res.SetIfPositive(venue, mActiveMarkets, float64(active)) + res.SetIfPositive(venue, mTopVol24h, topVol) + fmt.Printf("[perp-cohort][%s][%s] ok: active=%d vol24h=%.0f oi=%.0f top24h=%.0f\n", + venue, srcDydxNative, active, volSum, oiSum, topVol) + return res, nil +} + +func (s *DydxNativeSource) get(url string) ([]byte, error) { + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("User-Agent", "OpenChainBench-PerpCohort/1.0 contact@mobula.io") + req.Header.Set("Accept", "application/json") + resp, err := s.client.Do(req) + if err != nil { + return nil, fmt.Errorf("request_error: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return nil, fmt.Errorf("status_%d: %s", resp.StatusCode, truncate(string(body), 200)) + } + return body, nil +} diff --git a/harnesses/perp-cohort-stats/cmd/script/source_edgex.go b/harnesses/perp-cohort-stats/cmd/script/source_edgex.go new file mode 100644 index 00000000..797216e7 --- /dev/null +++ b/harnesses/perp-cohort-stats/cmd/script/source_edgex.go @@ -0,0 +1,230 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "sync" + "time" +) + +// EdgexNativeSource hits two endpoints: +// +// GET https://pro.edgex.exchange/api/v1/public/meta/getMetaData +// GET https://pro.edgex.exchange/api/v1/public/quote/getTicker?contractId= +// +// edgeX does not expose a single ticker batch endpoint, so we walk +// the ~290 contracts one at a time with a 10 req/s throttle. A full +// loop takes ~30s, so we run the loop in a background goroutine and +// cache the per-contract numbers for 5 minutes. Each Fetch() call +// returns immediately with whatever snapshot is currently cached; +// the first sweep emits zero, subsequent sweeps emit the cached map. +// +// Each per-contract ticker response has the rollup fields we need: +// +// value (24h notional USD, string) +// openInterest (BASE units, string) +// markPrice (USD, string) +type EdgexNativeSource struct { + client *http.Client + mu sync.Mutex + cache map[string]edgexTickerRow + cacheTS time.Time + refreshing bool +} + +type edgexTickerRow struct { + value float64 + oi float64 + mark float64 +} + +func NewEdgexNativeSource() *EdgexNativeSource { + return &EdgexNativeSource{ + client: &http.Client{Timeout: 15 * time.Second}, + cache: map[string]edgexTickerRow{}, + } +} + +func (s *EdgexNativeSource) Name() string { return srcEdgexNative } + +type edgexMetaResponse struct { + Code string `json:"code"` + Data struct { + ContractList []struct { + ContractID string `json:"contractId"` + ContractName string `json:"contractName"` + } `json:"contractList"` + } `json:"data"` +} + +type edgexTickerResponse struct { + Code string `json:"code"` + Data []struct { + ContractID string `json:"contractId"` + ContractName string `json:"contractName"` + Value string `json:"value"` + OpenInterest string `json:"openInterest"` + MarkPrice string `json:"markPrice"` + } `json:"data"` +} + +const edgexCacheTTL = 5 * time.Minute + +func (s *EdgexNativeSource) Fetch() (*SourceResult, error) { + res := newSourceResult() + venue := "edgex" + + // Fetch contract list every sweep (small, fast, one request). + body, err := s.get("https://pro.edgex.exchange/api/v1/public/meta/getMetaData") + if err != nil { + perpCohortFetchErrors.WithLabelValues(venue, srcEdgexNative, classifyError(err.Error())).Inc() + fmt.Printf("[perp-cohort][%s][%s] err meta: %v\n", venue, srcEdgexNative, err) + return res, nil + } + var meta edgexMetaResponse + if err := json.Unmarshal(body, &meta); err != nil { + perpCohortFetchErrors.WithLabelValues(venue, srcEdgexNative, "parse").Inc() + fmt.Printf("[perp-cohort][%s][%s] err parse meta: %v\n", venue, srcEdgexNative, err) + return res, nil + } + + // Schedule a per-contract refresh in the background if the cache + // is stale; aggregate from whatever snapshot is currently cached. + // The per-contract endpoint sits behind a stricter Cloudflare WAF + // than the meta endpoint and frequently 403s under steady traffic, + // so we treat the cohort cache as best-effort: when the cache is + // empty we publish only active_markets (count of mainnet contracts) + // and rely on the DefiLlama fallback for vol/oi. + s.maybeRefresh(meta.Data.ContractList) + + s.mu.Lock() + var volSum, oiSum, topVol float64 + var active int + for _, row := range s.cache { + if row.mark <= 0 { + continue + } + active++ + volSum += row.value + if row.value > topVol { + topVol = row.value + } + oiSum += row.oi * row.mark + } + cached := len(s.cache) + s.mu.Unlock() + + // active_markets always comes from the meta call (it succeeds even + // when the per-contract WAF is hot). The other gauges are gated on + // the cache having content. + res.SetIfPositive(venue, mActiveMarkets, float64(len(meta.Data.ContractList))) + res.SetIfPositive(venue, mVolume24h, volSum) + res.SetIfPositive(venue, mOI, oiSum) + res.SetIfPositive(venue, mTopVol24h, topVol) + fmt.Printf("[perp-cohort][%s][%s] ok: contracts=%d cached=%d active=%d vol24h=%.0f oi=%.0f top24h=%.0f\n", + venue, srcEdgexNative, len(meta.Data.ContractList), cached, active, volSum, oiSum, topVol) + return res, nil +} + +func (s *EdgexNativeSource) maybeRefresh(contracts []struct { + ContractID string `json:"contractId"` + ContractName string `json:"contractName"` +}) { + s.mu.Lock() + if s.refreshing { + s.mu.Unlock() + return + } + if time.Since(s.cacheTS) < edgexCacheTTL && len(s.cache) > 0 { + s.mu.Unlock() + return + } + s.refreshing = true + s.mu.Unlock() + + go s.runRefresh(contracts) +} + +func (s *EdgexNativeSource) runRefresh(contracts []struct { + ContractID string `json:"contractId"` + ContractName string `json:"contractName"` +}) { + defer func() { + s.mu.Lock() + s.refreshing = false + s.mu.Unlock() + }() + + fresh := map[string]edgexTickerRow{} + // 5 req/s: edgeX's per-contract endpoint is stricter than the meta + // endpoint; this rate has tested clean from Railway without WAF + // pushback. A full ~290-contract sweep at this rate takes ~58s and + // fits inside the 60s tick boundary. + tick := time.NewTicker(200 * time.Millisecond) + defer tick.Stop() + var fails int + for _, c := range contracts { + <-tick.C + tBody, err := s.get(fmt.Sprintf( + "https://pro.edgex.exchange/api/v1/public/quote/getTicker?contractId=%s", + c.ContractID, + )) + if err != nil { + fails++ + continue + } + var tk edgexTickerResponse + if err := json.Unmarshal(tBody, &tk); err != nil { + fails++ + continue + } + if len(tk.Data) == 0 { + continue + } + row := tk.Data[0] + mark, _ := strconv.ParseFloat(row.MarkPrice, 64) + val, _ := strconv.ParseFloat(row.Value, 64) + oi, _ := strconv.ParseFloat(row.OpenInterest, 64) + fresh[c.ContractID] = edgexTickerRow{value: val, oi: oi, mark: mark} + } + + // Only swap the cache in if we got a meaningful refresh; partial + // refreshes (most contracts blocked by WAF) would deflate the + // gauge to false-low values, so we keep the previous snapshot + // when the refresh was clearly degraded. + s.mu.Lock() + defer s.mu.Unlock() + if len(fresh) < len(contracts)/2 { + fmt.Printf("[perp-cohort][edgex][%s] cache refresh DEGRADED: %d/%d ok, %d fails; keeping prior snapshot (%d)\n", + srcEdgexNative, len(fresh), len(contracts), fails, len(s.cache)) + return + } + s.cache = fresh + s.cacheTS = time.Now() + fmt.Printf("[perp-cohort][edgex][%s] cache refreshed: %d contracts (%d fails)\n", + srcEdgexNative, len(fresh), fails) +} + +func (s *EdgexNativeSource) get(url string) ([]byte, error) { + req, _ := http.NewRequest("GET", url, nil) + // edgeX sits behind a Cloudflare WAF that 403s any non-browser UA; + // fronting a Mozilla string gets us through. The contact email is + // kept inside an X-Contact header so the operator is still + // identifiable to a human reviewing access logs. + req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; OpenChainBench-PerpCohort/1.0)") + req.Header.Set("X-Contact", "contact@mobula.io") + req.Header.Set("Accept", "application/json") + resp, err := s.client.Do(req) + if err != nil { + return nil, fmt.Errorf("request_error: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return nil, fmt.Errorf("status_%d: %s", resp.StatusCode, truncate(string(body), 200)) + } + return body, nil +} diff --git a/harnesses/perp-cohort-stats/cmd/script/source_extended.go b/harnesses/perp-cohort-stats/cmd/script/source_extended.go new file mode 100644 index 00000000..b9183e17 --- /dev/null +++ b/harnesses/perp-cohort-stats/cmd/script/source_extended.go @@ -0,0 +1,130 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "time" +) + +// ExtendedNativeSource calls the Extended (formerly X10) Starknet +// gateway: +// +// GET https://api.starknet.extended.exchange/api/v1/info/markets +// +// The response is a single envelope: `{ "status": "OK", "data": [...] }` +// with one row per market. Each PERPETUAL row exposes the rollup +// fields under `marketStats`: +// +// dailyVolume (USD notional, string) +// openInterest (USD notional, string) +// openInterestBase (BASE units, string) +// markPrice (USD, string) +// indexPrice (USD, string) +// +// Only one HTTP call covers volume + OI + active count + top market, +// which keeps the per-tick fan-out at a single request. +// +// Derived metrics: +// +// volume_24h_usd = sum(dailyVolume) over ACTIVE perp rows +// oi_usd = sum(openInterest) over ACTIVE perp rows +// active_markets = count(active PERPETUAL rows with markPrice > 0) +// top_market_volume_24h_usd = max(dailyVolume) +type ExtendedNativeSource struct { + client *http.Client +} + +func NewExtendedNativeSource() *ExtendedNativeSource { + return &ExtendedNativeSource{ + client: &http.Client{Timeout: 15 * time.Second}, + } +} + +func (s *ExtendedNativeSource) Name() string { return srcExtendedNative } + +type extendedMarketStats struct { + DailyVolume string `json:"dailyVolume"` + OpenInterest string `json:"openInterest"` + MarkPrice string `json:"markPrice"` +} + +type extendedMarket struct { + Name string `json:"name"` + Type string `json:"type"` + Status string `json:"status"` + MarketStats extendedMarketStats `json:"marketStats"` +} + +type extendedResponse struct { + Status string `json:"status"` + Data []extendedMarket `json:"data"` +} + +func (s *ExtendedNativeSource) Fetch() (*SourceResult, error) { + res := newSourceResult() + venue := "extended" + + body, err := s.get("https://api.starknet.extended.exchange/api/v1/info/markets") + if err != nil { + perpCohortFetchErrors.WithLabelValues(venue, srcExtendedNative, classifyError(err.Error())).Inc() + fmt.Printf("[perp-cohort][%s][%s] err: %v\n", venue, srcExtendedNative, err) + return res, nil + } + + var parsed extendedResponse + if err := json.Unmarshal(body, &parsed); err != nil { + perpCohortFetchErrors.WithLabelValues(venue, srcExtendedNative, "parse").Inc() + fmt.Printf("[perp-cohort][%s][%s] err parse: %v\n", venue, srcExtendedNative, err) + return res, nil + } + + var volSum, oiSum, topVol float64 + var active int + for _, m := range parsed.Data { + if m.Type != "PERPETUAL" || m.Status != "ACTIVE" { + continue + } + mark, _ := strconv.ParseFloat(m.MarketStats.MarkPrice, 64) + if mark <= 0 { + continue + } + active++ + v, _ := strconv.ParseFloat(m.MarketStats.DailyVolume, 64) + volSum += v + if v > topVol { + topVol = v + } + // openInterest on Extended is already USD-quoted (verified live + // against the dashboard: BTC OI string matches the dollar value + // shown in the UI), so no base*mark multiplication. + oi, _ := strconv.ParseFloat(m.MarketStats.OpenInterest, 64) + oiSum += oi + } + + res.SetIfPositive(venue, mVolume24h, volSum) + res.SetIfPositive(venue, mOI, oiSum) + res.SetIfPositive(venue, mActiveMarkets, float64(active)) + res.SetIfPositive(venue, mTopVol24h, topVol) + fmt.Printf("[perp-cohort][%s][%s] ok: active=%d vol24h=%.0f oi=%.0f top24h=%.0f\n", + venue, srcExtendedNative, active, volSum, oiSum, topVol) + return res, nil +} + +func (s *ExtendedNativeSource) get(url string) ([]byte, error) { + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("User-Agent", "OpenChainBench-PerpCohort/1.0 contact@mobula.io") + req.Header.Set("Accept", "application/json") + resp, err := s.client.Do(req) + if err != nil { + return nil, fmt.Errorf("request_error: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return nil, fmt.Errorf("status_%d: %s", resp.StatusCode, truncate(string(body), 200)) + } + return body, nil +} diff --git a/harnesses/perp-cohort-stats/cmd/script/source_grvt.go b/harnesses/perp-cohort-stats/cmd/script/source_grvt.go new file mode 100644 index 00000000..952fd133 --- /dev/null +++ b/harnesses/perp-cohort-stats/cmd/script/source_grvt.go @@ -0,0 +1,200 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "sync" + "time" +) + +// GrvtNativeSource hits the GRVT market-data POST API (public, no +// auth): +// +// POST https://market-data.grvt.io/full/v1/all_instruments {} +// POST https://market-data.grvt.io/full/v1/ticker {"instrument": ""} +// +// /all_instruments returns one row per tradable contract. We filter +// kind == "PERPETUAL". /ticker has no bulk shape, so the per-symbol +// loop runs in a background goroutine throttled to 10 req/s and the +// resulting USD volumes + open interest map is cached for 5 minutes. +// At ~170 perp markets the background loop takes ~17s. +// +// Derived metrics: +// +// volume_24h_usd = sum(buy_volume_24h_q + sell_volume_24h_q) +// oi_usd = sum(open_interest * mark_price) +// active_markets = count(perpetual instruments) +// top_market_volume_24h_usd = max(per-market 24h USD volume) +type GrvtNativeSource struct { + client *http.Client + mu sync.Mutex + cache map[string]grvtTickerRow + cacheTS time.Time + refreshing bool +} + +type grvtTickerRow struct { + volumeQ float64 + oi float64 + mark float64 +} + +func NewGrvtNativeSource() *GrvtNativeSource { + return &GrvtNativeSource{ + client: &http.Client{Timeout: 15 * time.Second}, + cache: map[string]grvtTickerRow{}, + } +} + +func (s *GrvtNativeSource) Name() string { return srcGrvtNative } + +type grvtInstrument struct { + Instrument string `json:"instrument"` + Kind string `json:"kind"` +} + +type grvtInstrumentsResponse struct { + Result []grvtInstrument `json:"result"` +} + +type grvtTickerResponse struct { + Result struct { + Instrument string `json:"instrument"` + MarkPrice string `json:"mark_price"` + BuyVolume24hQ string `json:"buy_volume_24h_q"` + SellVolume24hQ string `json:"sell_volume_24h_q"` + OpenInterest string `json:"open_interest"` + } `json:"result"` +} + +const grvtCacheTTL = 5 * time.Minute + +func (s *GrvtNativeSource) Fetch() (*SourceResult, error) { + res := newSourceResult() + venue := "grvt" + + // Step 1: instrument catalog (small, fast, one request). + body, err := s.post("https://market-data.grvt.io/full/v1/all_instruments", []byte(`{}`)) + if err != nil { + perpCohortFetchErrors.WithLabelValues(venue, srcGrvtNative, classifyError(err.Error())).Inc() + fmt.Printf("[perp-cohort][%s][%s] err catalog: %v\n", venue, srcGrvtNative, err) + return res, nil + } + var catalog grvtInstrumentsResponse + if err := json.Unmarshal(body, &catalog); err != nil { + perpCohortFetchErrors.WithLabelValues(venue, srcGrvtNative, "parse").Inc() + fmt.Printf("[perp-cohort][%s][%s] err parse catalog: %v\n", venue, srcGrvtNative, err) + return res, nil + } + var perps []string + for _, i := range catalog.Result { + if i.Kind != "PERPETUAL" { + continue + } + perps = append(perps, i.Instrument) + } + + // Step 2: kick off async per-symbol ticker refresh if stale. + s.maybeRefresh(perps) + + // Step 3: aggregate from whatever cache snapshot exists. + s.mu.Lock() + var volSum, oiSum, topVol float64 + var active int + for _, row := range s.cache { + if row.mark <= 0 { + continue + } + active++ + volSum += row.volumeQ + if row.volumeQ > topVol { + topVol = row.volumeQ + } + oiSum += row.oi * row.mark + } + cached := len(s.cache) + s.mu.Unlock() + + // active_markets is always emitted from the catalog (the cache may + // be empty on the first sweep). The other gauges are conditioned + // on SetIfPositive so they only land after the cache fills. + res.SetIfPositive(venue, mActiveMarkets, float64(len(perps))) + res.SetIfPositive(venue, mVolume24h, volSum) + res.SetIfPositive(venue, mOI, oiSum) + res.SetIfPositive(venue, mTopVol24h, topVol) + fmt.Printf("[perp-cohort][%s][%s] ok: perps=%d cached=%d active=%d vol24h=%.0f oi=%.0f top24h=%.0f\n", + venue, srcGrvtNative, len(perps), cached, active, volSum, oiSum, topVol) + return res, nil +} + +func (s *GrvtNativeSource) maybeRefresh(symbols []string) { + s.mu.Lock() + if s.refreshing { + s.mu.Unlock() + return + } + if time.Since(s.cacheTS) < grvtCacheTTL && len(s.cache) > 0 { + s.mu.Unlock() + return + } + s.refreshing = true + s.mu.Unlock() + + go s.runRefresh(symbols) +} + +func (s *GrvtNativeSource) runRefresh(symbols []string) { + defer func() { + s.mu.Lock() + s.refreshing = false + s.mu.Unlock() + }() + + fresh := map[string]grvtTickerRow{} + tick := time.NewTicker(100 * time.Millisecond) + defer tick.Stop() + for _, sym := range symbols { + <-tick.C + req := fmt.Sprintf(`{"instrument":%q}`, sym) + body, err := s.post("https://market-data.grvt.io/full/v1/ticker", []byte(req)) + if err != nil { + continue + } + var tk grvtTickerResponse + if err := json.Unmarshal(body, &tk); err != nil { + continue + } + mark, _ := strconv.ParseFloat(tk.Result.MarkPrice, 64) + buy, _ := strconv.ParseFloat(tk.Result.BuyVolume24hQ, 64) + sell, _ := strconv.ParseFloat(tk.Result.SellVolume24hQ, 64) + oi, _ := strconv.ParseFloat(tk.Result.OpenInterest, 64) + fresh[sym] = grvtTickerRow{volumeQ: buy + sell, oi: oi, mark: mark} + } + + s.mu.Lock() + s.cache = fresh + s.cacheTS = time.Now() + s.mu.Unlock() + fmt.Printf("[perp-cohort][grvt][%s] cache refreshed: %d perps\n", srcGrvtNative, len(fresh)) +} + +func (s *GrvtNativeSource) post(url string, body []byte) ([]byte, error) { + req, _ := http.NewRequest("POST", url, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "OpenChainBench-PerpCohort/1.0 contact@mobula.io") + req.Header.Set("Accept", "application/json") + resp, err := s.client.Do(req) + if err != nil { + return nil, fmt.Errorf("request_error: %w", err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return nil, fmt.Errorf("status_%d: %s", resp.StatusCode, truncate(string(b), 200)) + } + return b, nil +} diff --git a/harnesses/perp-cohort-stats/cmd/script/source_hyperliquid.go b/harnesses/perp-cohort-stats/cmd/script/source_hyperliquid.go new file mode 100644 index 00000000..909bf778 --- /dev/null +++ b/harnesses/perp-cohort-stats/cmd/script/source_hyperliquid.go @@ -0,0 +1,151 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "time" +) + +// HyperliquidNativeSource calls the public info endpoint: +// +// POST https://api.hyperliquid.xyz/info body: {"type":"metaAndAssetCtxs"} +// +// The response is a tuple `[meta, ctxs]`: +// - meta.universe is an array of assets (BTC, ETH, ATOM, ...) with +// szDecimals and a possible `isDelisted` flag. +// - ctxs is a parallel array of contexts, each with stringified +// numeric fields: markPx, openInterest, dayNtlVlm (notional 24h +// volume in USD), funding, prevDayPx. +// +// Derived metrics: +// +// volume_24h_usd = sum(dayNtlVlm) +// oi_usd = sum(openInterest * markPx) +// active_markets = count(non-delisted universe entries) +// top_market_volume_24h_usd = max(dayNtlVlm) +type HyperliquidNativeSource struct { + client *http.Client +} + +func NewHyperliquidNativeSource() *HyperliquidNativeSource { + return &HyperliquidNativeSource{ + client: &http.Client{Timeout: 15 * time.Second}, + } +} + +func (s *HyperliquidNativeSource) Name() string { return srcHLNative } + +type hlMeta struct { + Universe []hlUniverseEntry `json:"universe"` +} + +type hlUniverseEntry struct { + Name string `json:"name"` + IsDelisted bool `json:"isDelisted"` +} + +// hlCtx fields arrive as JSON strings; we parse manually rather than +// drop a flexFloat custom unmarshaler here because the struct is tight. +type hlCtx struct { + DayNtlVlm string `json:"dayNtlVlm"` + OpenInterest string `json:"openInterest"` + MarkPx string `json:"markPx"` + Funding string `json:"funding"` +} + +func (s *HyperliquidNativeSource) Fetch() (*SourceResult, error) { + res := newSourceResult() + venue := "hyperliquid" + + url := "https://api.hyperliquid.xyz/info" + body, err := s.post(url, []byte(`{"type":"metaAndAssetCtxs"}`)) + if err != nil { + perpCohortFetchErrors.WithLabelValues(venue, srcHLNative, classifyError(err.Error())).Inc() + fmt.Printf("[perp-cohort][%s][%s] err: %v\n", venue, srcHLNative, err) + return res, nil + } + + // Response shape: [meta, ctxs]. meta is decoded once for universe + // length / delisted flags, ctxs is decoded as an array of objects. + var raw []json.RawMessage + if err := json.Unmarshal(body, &raw); err != nil || len(raw) != 2 { + perpCohortFetchErrors.WithLabelValues(venue, srcHLNative, "parse").Inc() + fmt.Printf("[perp-cohort][%s][%s] err: parse top-level: %v\n", venue, srcHLNative, err) + return res, nil + } + var meta hlMeta + if err := json.Unmarshal(raw[0], &meta); err != nil { + perpCohortFetchErrors.WithLabelValues(venue, srcHLNative, "parse").Inc() + fmt.Printf("[perp-cohort][%s][%s] err: parse meta: %v\n", venue, srcHLNative, err) + return res, nil + } + var ctxs []hlCtx + if err := json.Unmarshal(raw[1], &ctxs); err != nil { + perpCohortFetchErrors.WithLabelValues(venue, srcHLNative, "parse").Inc() + fmt.Printf("[perp-cohort][%s][%s] err: parse ctxs: %v\n", venue, srcHLNative, err) + return res, nil + } + + var volSum, oiSum, topVol float64 + var activeCount int + for i, c := range ctxs { + if i >= len(meta.Universe) { + break + } + if meta.Universe[i].IsDelisted { + continue + } + activeCount++ + v := parseFloat(c.DayNtlVlm) + volSum += v + if v > topVol { + topVol = v + } + oi := parseFloat(c.OpenInterest) + mark := parseFloat(c.MarkPx) + oiSum += oi * mark + } + + // Skip metrics that came back as zero. A truly zero value means the + // venue is dead, the upstream had an outage, or the response shape + // drifted: in every case carry-forward beats overwriting with 0. + res.SetIfPositive(venue, mVolume24h, volSum) + res.SetIfPositive(venue, mOI, oiSum) + res.SetIfPositive(venue, mActiveMarkets, float64(activeCount)) + res.SetIfPositive(venue, mTopVol24h, topVol) + fmt.Printf("[perp-cohort][%s][%s] ok: active=%d vol24h=%.0f oi=%.0f top24h=%.0f\n", + venue, srcHLNative, activeCount, volSum, oiSum, topVol) + return res, nil +} + +func (s *HyperliquidNativeSource) post(url string, body []byte) ([]byte, error) { + req, _ := http.NewRequest("POST", url, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "OpenChainBench-PerpCohort/1.0 contact@mobula.io") + req.Header.Set("Accept", "application/json") + resp, err := s.client.Do(req) + if err != nil { + return nil, fmt.Errorf("request_error: %w", err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return nil, fmt.Errorf("status_%d: %s", resp.StatusCode, truncate(string(b), 200)) + } + return b, nil +} + +func parseFloat(s string) float64 { + if s == "" { + return 0 + } + v, err := strconv.ParseFloat(s, 64) + if err != nil { + return 0 + } + return v +} diff --git a/harnesses/perp-cohort-stats/cmd/script/source_lighter.go b/harnesses/perp-cohort-stats/cmd/script/source_lighter.go new file mode 100644 index 00000000..c2638eba --- /dev/null +++ b/harnesses/perp-cohort-stats/cmd/script/source_lighter.go @@ -0,0 +1,142 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "time" +) + +// LighterNativeSource hits Lighter's public mainnet info endpoints: +// +// GET https://mainnet.zklighter.elliot.ai/api/v1/exchangeStats +// GET https://mainnet.zklighter.elliot.ai/api/v1/orderBookDetails +// +// /exchangeStats does NOT directly expose a clean cohort-level +// volume_24h_usd field at the venue level today; the populated source +// of 24h dollar volume is the per-market order_book_stats array, which +// we sum. The same array is the source for the top-market figure. +// +// /orderBookDetails returns one row per perp market with an +// `open_interest` field (base units) and `last_trade_price` (USD). +// We sum open_interest * last_trade_price across active markets for +// the OI gauge. active_markets is the count of markets with +// `status=="active"`. +type LighterNativeSource struct { + client *http.Client +} + +func NewLighterNativeSource() *LighterNativeSource { + return &LighterNativeSource{ + client: &http.Client{Timeout: 15 * time.Second}, + } +} + +func (s *LighterNativeSource) Name() string { return srcLighterNative } + +type lighterExchangeStats struct { + Code int `json:"code"` + Total int `json:"total"` + OrderBookStats []lighterMarketStats `json:"order_book_stats"` +} + +type lighterMarketStats struct { + Symbol string `json:"symbol"` + LastTradePrice float64 `json:"last_trade_price"` + DailyTradesCount float64 `json:"daily_trades_count"` + DailyQuoteTokenVolume float64 `json:"daily_quote_token_volume"` + DailyBaseTokenVolume float64 `json:"daily_base_token_volume"` +} + +type lighterOrderBookDetails struct { + Code int `json:"code"` + OrderBookDetails []lighterOrderBookDetail `json:"order_book_details"` +} + +type lighterOrderBookDetail struct { + Symbol string `json:"symbol"` + MarketType string `json:"market_type"` + Status string `json:"status"` + OpenInterest float64 `json:"open_interest"` + LastTradePrice float64 `json:"last_trade_price"` +} + +func (s *LighterNativeSource) Fetch() (*SourceResult, error) { + res := newSourceResult() + venue := "lighter" + + // Pass 1: /exchangeStats for 24h volume + top-market volume. + body, err := s.get("https://mainnet.zklighter.elliot.ai/api/v1/exchangeStats") + if err != nil { + perpCohortFetchErrors.WithLabelValues(venue, srcLighterNative, classifyError(err.Error())).Inc() + fmt.Printf("[perp-cohort][%s][%s] err exchangeStats: %v\n", venue, srcLighterNative, err) + } else { + var es lighterExchangeStats + if err := json.Unmarshal(body, &es); err != nil { + perpCohortFetchErrors.WithLabelValues(venue, srcLighterNative, "parse").Inc() + fmt.Printf("[perp-cohort][%s][%s] err parse exchangeStats: %v\n", venue, srcLighterNative, err) + } else { + var volSum, topVol float64 + for _, m := range es.OrderBookStats { + volSum += m.DailyQuoteTokenVolume + if m.DailyQuoteTokenVolume > topVol { + topVol = m.DailyQuoteTokenVolume + } + } + res.SetIfPositive(venue, mVolume24h, volSum) + res.SetIfPositive(venue, mTopVol24h, topVol) + fmt.Printf("[perp-cohort][%s][%s] ok exchangeStats: markets=%d vol24h=%.0f top24h=%.0f\n", + venue, srcLighterNative, len(es.OrderBookStats), volSum, topVol) + } + } + + // Pass 2: /orderBookDetails for OI sum + active market count. + body2, err := s.get("https://mainnet.zklighter.elliot.ai/api/v1/orderBookDetails") + if err != nil { + perpCohortFetchErrors.WithLabelValues(venue, srcLighterNative, classifyError(err.Error())).Inc() + fmt.Printf("[perp-cohort][%s][%s] err orderBookDetails: %v\n", venue, srcLighterNative, err) + } else { + var ob lighterOrderBookDetails + if err := json.Unmarshal(body2, &ob); err != nil { + perpCohortFetchErrors.WithLabelValues(venue, srcLighterNative, "parse").Inc() + fmt.Printf("[perp-cohort][%s][%s] err parse orderBookDetails: %v\n", venue, srcLighterNative, err) + } else { + var oiSum float64 + var active int + for _, m := range ob.OrderBookDetails { + if m.MarketType != "perp" || m.Status != "active" { + continue + } + active++ + oiSum += m.OpenInterest * m.LastTradePrice + } + res.SetIfPositive(venue, mOI, oiSum) + res.SetIfPositive(venue, mActiveMarkets, float64(active)) + fmt.Printf("[perp-cohort][%s][%s] ok orderBookDetails: active=%d oi=%.0f\n", + venue, srcLighterNative, active, oiSum) + } + } + + return res, nil +} + +func (s *LighterNativeSource) get(url string) ([]byte, error) { + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("User-Agent", "OpenChainBench-PerpCohort/1.0 contact@mobula.io") + req.Header.Set("Accept", "application/json") + resp, err := s.client.Do(req) + if err != nil { + return nil, fmt.Errorf("request_error: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return nil, fmt.Errorf("status_%d: %s", resp.StatusCode, truncate(string(body), 200)) + } + return body, nil +} + +// unused but kept for the parse helpers in case the API changes shape. +var _ = strconv.ParseFloat diff --git a/harnesses/perp-cohort-stats/cmd/script/source_mobula.go b/harnesses/perp-cohort-stats/cmd/script/source_mobula.go new file mode 100644 index 00000000..f12cab56 --- /dev/null +++ b/harnesses/perp-cohort-stats/cmd/script/source_mobula.go @@ -0,0 +1,134 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// MobulaFundingSource hits the Mobula CEFI funding-rate aggregator: +// +// GET https://api.mobula.io/api/1/market/cefi/funding-rate? +// symbol=&exchange= +// +// One call returns a JSON object with `FundingRate` keys (e.g. +// `hyperliquidFundingRate`, `lighterFundingRate`). Each entry has +// `fundingRate` (decimal per epoch) and `epochDurationMs` (epoch length +// in ms). We normalize to a 24h figure in basis points: +// +// bps_24h = fundingRate * (86_400_000 / epochDurationMs) * 10_000 +// +// We loop over the 3 OCB-anchor assets (BTC, ETH, SOL), so the per-tick +// cost is 3 requests * 12-venue fan-out per response = the full 12x3 grid. +type MobulaFundingSource struct { + apiKey string + exchangeList string + client *http.Client +} + +func NewMobulaFundingSource(apiKey, exchangeList string) *MobulaFundingSource { + return &MobulaFundingSource{ + apiKey: apiKey, + exchangeList: exchangeList, + client: &http.Client{Timeout: 15 * time.Second}, + } +} + +func (s *MobulaFundingSource) Name() string { return srcMobulaFund } + +// Mobula key naming: `FundingRate`. We map this back +// to the OCB perp slugs in venueFromMobulaKey. Only venues present in +// the priority map for funding are emitted; the rest of the grid is +// kept so the funding gauge cardinality covers the broader CEFI cohort +// the UI may render next to the perp DEX strip. +type mobulaFundingEntry struct { + Symbol string `json:"symbol"` + FundingRate float64 `json:"fundingRate"` + EpochDurationMs float64 `json:"epochDurationMs"` + FundingTime float64 `json:"fundingTime"` +} + +func (s *MobulaFundingSource) Fetch() (*SourceResult, error) { + res := newSourceResult() + assets := []string{"BTC", "ETH", "SOL"} + for _, asset := range assets { + if err := s.fetchOne(asset, res); err != nil { + // Per-asset failures are bucketed under the funding source name; + // we attribute to the "all-venues" carrier slug so the counter + // stays meaningful without exploding cardinality. + perpCohortFetchErrors.WithLabelValues("_all", srcMobulaFund, classifyError(err.Error())).Inc() + fmt.Printf("[perp-cohort][_][%s] asset=%s err: %v\n", srcMobulaFund, asset, err) + continue + } + } + return res, nil +} + +func (s *MobulaFundingSource) fetchOne(asset string, res *SourceResult) error { + url := fmt.Sprintf("https://api.mobula.io/api/1/market/cefi/funding-rate?symbol=%s&exchange=%s", + asset, s.exchangeList) + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("Authorization", s.apiKey) + req.Header.Set("User-Agent", "OpenChainBench-PerpCohort/1.0 contact@mobula.io") + req.Header.Set("Accept", "application/json") + resp, err := s.client.Do(req) + if err != nil { + return fmt.Errorf("request_error: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return fmt.Errorf("status_%d: %s", resp.StatusCode, truncate(string(body), 200)) + } + + // The response is a flat object with `FundingRate` keys plus a + // `queryDetails` sibling. We decode into a map and iterate. + var raw map[string]json.RawMessage + if err := json.Unmarshal(body, &raw); err != nil { + return fmt.Errorf("parse: %w", err) + } + for key, v := range raw { + if !strings.HasSuffix(key, "FundingRate") { + continue + } + venueKey := strings.TrimSuffix(key, "FundingRate") + var entry mobulaFundingEntry + if err := json.Unmarshal(v, &entry); err != nil { + continue + } + if entry.EpochDurationMs <= 0 { + continue + } + bps24h := entry.FundingRate * (86_400_000.0 / entry.EpochDurationMs) * 10_000.0 + intervalHours := entry.EpochDurationMs / 3_600_000.0 + venueSlug := venueFromMobulaKey(venueKey) + res.SetFunding(venueSlug, asset, fundingPoint{ + Bps24h: bps24h, + IntervalHours: intervalHours, + }) + } + fmt.Printf("[perp-cohort][_][%s] asset=%s ok: %d venues\n", srcMobulaFund, asset, len(res.Funding)) + return nil +} + +// venueFromMobulaKey maps Mobula's lowercase venue key (e.g. "hyperliquid", +// "okx", "binance") to the OCB venue slug. The mapping is identity for +// most venues; the few exceptions are listed here so the funding gauge +// labels match the perp registry exactly when the venue is in cohort. +func venueFromMobulaKey(k string) string { + switch k { + case "hyperliquid": + return "hyperliquid" + case "lighter": + return "lighter" + default: + // Pass-through for CEFI venues (binance, bybit, okx, ...). They + // are not in the OCB perp DEX cohort today, but the funding + // gauge surface includes them so adjacent UI panels (CEFI vs + // DEX funding spread) can read straight from this harness. + return k + } +} diff --git a/harnesses/perp-cohort-stats/cmd/script/source_mobula_pairs.go b/harnesses/perp-cohort-stats/cmd/script/source_mobula_pairs.go new file mode 100644 index 00000000..9de5e2a7 --- /dev/null +++ b/harnesses/perp-cohort-stats/cmd/script/source_mobula_pairs.go @@ -0,0 +1,98 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// MobulaPairsSource hits the Mobula perp pairs catalog: +// +// GET https://api.mobula.io/api/2/perp/pairs +// +// One call returns the full catalog across every dex Mobula tracks +// today (Lighter, Gains, plus Mobula-side test fixtures). We bucket +// by `dex` field and emit `active_markets` per venue. +// +// Why this source: Mobula already curates the markets list for these +// two venues with `assetClass` tags (crypto, forex, stocks, commodities, +// indices, degen, new). Hitting Mobula once beats hitting each venue +// native API and avoids duplicating the asset-class taxonomy harness +// side. +type MobulaPairsSource struct { + apiKey string + client *http.Client +} + +func NewMobulaPairsSource(apiKey string) *MobulaPairsSource { + return &MobulaPairsSource{ + apiKey: apiKey, + client: &http.Client{Timeout: 15 * time.Second}, + } +} + +func (s *MobulaPairsSource) Name() string { return srcMobulaPairs } + +type mobulaPair struct { + Dex string `json:"dex"` + Chain string `json:"chain"` + AssetClass string `json:"assetClass"` +} + +type mobulaPairsResponse struct { + Data []mobulaPair `json:"data"` +} + +func (s *MobulaPairsSource) Fetch() (*SourceResult, error) { + res := newSourceResult() + url := "https://api.mobula.io/api/2/perp/pairs" + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return res, err + } + if s.apiKey != "" { + req.Header.Set("Authorization", s.apiKey) + } + req.Header.Set("User-Agent", "OpenChainBench-PerpCohort/1.0 contact@mobula.io") + + resp, err := s.client.Do(req) + if err != nil { + perpCohortFetchErrors.WithLabelValues("all", srcMobulaPairs, classifyError(err.Error())).Inc() + return res, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + if resp.StatusCode != 200 { + perpCohortFetchErrors.WithLabelValues("all", srcMobulaPairs, fmt.Sprintf("http_%d", resp.StatusCode)).Inc() + return res, fmt.Errorf("mobula pairs http %d", resp.StatusCode) + } + + var parsed mobulaPairsResponse + if err := json.Unmarshal(body, &parsed); err != nil { + perpCohortFetchErrors.WithLabelValues("all", srcMobulaPairs, "parse").Inc() + return res, err + } + + // Bucket by dex -> count. Mobula's dex tag uses lower-case venue + // slugs that match the OCB harness convention (gains, lighter, ...). + // We skip the testnet `arbitrum-sepolia` chain rows so the active + // markets gauge reflects mainnet inventory only. + counts := map[string]int{} + for _, p := range parsed.Data { + if p.Chain == "arbitrum-sepolia" { + continue + } + counts[p.Dex]++ + } + for dex, n := range counts { + res.Set(dex, mActiveMarkets, float64(n)) + } + fmt.Printf("[perp-cohort][mobula_pairs] ok: %d dexes, counts=%v\n", len(counts), counts) + return res, nil +} diff --git a/harnesses/perp-cohort-stats/cmd/script/source_ostium.go b/harnesses/perp-cohort-stats/cmd/script/source_ostium.go new file mode 100644 index 00000000..47a7431e --- /dev/null +++ b/harnesses/perp-cohort-stats/cmd/script/source_ostium.go @@ -0,0 +1,130 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "time" +) + +// OstiumNativeSource queries the Ormi-hosted Ostium mainnet subgraph +// for per-pair OI and active count. The subgraph URL is the one +// shipped inside the official ostium-python-sdk +// (config.py::NetworkConfig.mainnet); no API key is required: +// +// POST https://api.subgraph.ormilabs.com/api/public//subgraphs/ost-prod/live/gn +// +// Each Pair row exposes: +// +// id, from, to (e.g. "0", "BTC", "USD") +// longOI, shortOI (BASE units scaled by 1e18, string) +// lastTradePrice (USD scaled by 1e18, string) +// +// Volume 24h is NOT exposed as a rolling-window aggregate on the +// subgraph (only lifetime `volume` + buy/sell totals are available, in +// 1e8 USD scale), so this source publishes OI + active_markets only. +// The router keeps DefiLlama as the fallback for vol_24h / vol_30d +// (Ostium total24h on DefiLlama is live and matches the dashboard). +// +// Derived metrics: +// +// oi_usd = sum((longOI + shortOI) / 1e18 * lastTradePrice / 1e18) +// active_markets = count(pairs with lastTradePrice > 0) +type OstiumNativeSource struct { + client *http.Client +} + +func NewOstiumNativeSource() *OstiumNativeSource { + return &OstiumNativeSource{ + client: &http.Client{Timeout: 15 * time.Second}, + } +} + +func (s *OstiumNativeSource) Name() string { return srcOstiumNative } + +const ostiumSubgraphURL = "https://api.subgraph.ormilabs.com/api/public/67a599d5-c8d2-4cc4-9c4d-2975a97bc5d8/subgraphs/ost-prod/live/gn" + +const ostiumPairsQuery = `{ + pairs(first: 200) { + id + longOI + shortOI + lastTradePrice + } +}` + +type ostiumPair struct { + ID string `json:"id"` + LongOI string `json:"longOI"` + ShortOI string `json:"shortOI"` + LastTradePrice string `json:"lastTradePrice"` +} + +type ostiumResponse struct { + Data struct { + Pairs []ostiumPair `json:"pairs"` + } `json:"data"` +} + +func (s *OstiumNativeSource) Fetch() (*SourceResult, error) { + res := newSourceResult() + venue := "ostium" + + body, err := s.post(ostiumSubgraphURL, map[string]string{"query": ostiumPairsQuery}) + if err != nil { + perpCohortFetchErrors.WithLabelValues(venue, srcOstiumNative, classifyError(err.Error())).Inc() + fmt.Printf("[perp-cohort][%s][%s] err: %v\n", venue, srcOstiumNative, err) + return res, nil + } + + var parsed ostiumResponse + if err := json.Unmarshal(body, &parsed); err != nil { + perpCohortFetchErrors.WithLabelValues(venue, srcOstiumNative, "parse").Inc() + fmt.Printf("[perp-cohort][%s][%s] err parse: %v\n", venue, srcOstiumNative, err) + return res, nil + } + + const scale1e18 = 1e18 + var oiSum float64 + var active int + for _, p := range parsed.Data.Pairs { + longRaw, _ := strconv.ParseFloat(p.LongOI, 64) + shortRaw, _ := strconv.ParseFloat(p.ShortOI, 64) + pxRaw, _ := strconv.ParseFloat(p.LastTradePrice, 64) + if pxRaw <= 0 { + continue + } + active++ + long := longRaw / scale1e18 + short := shortRaw / scale1e18 + px := pxRaw / scale1e18 + oiSum += (long + short) * px + } + + res.SetIfPositive(venue, mOI, oiSum) + res.SetIfPositive(venue, mActiveMarkets, float64(active)) + fmt.Printf("[perp-cohort][%s][%s] ok: active=%d oi=%.0f\n", + venue, srcOstiumNative, active, oiSum) + return res, nil +} + +func (s *OstiumNativeSource) post(url string, payload any) ([]byte, error) { + buf, _ := json.Marshal(payload) + req, _ := http.NewRequest("POST", url, bytes.NewReader(buf)) + req.Header.Set("User-Agent", "OpenChainBench-PerpCohort/1.0 contact@mobula.io") + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + resp, err := s.client.Do(req) + if err != nil { + return nil, fmt.Errorf("request_error: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return nil, fmt.Errorf("status_%d: %s", resp.StatusCode, truncate(string(body), 200)) + } + return body, nil +} diff --git a/harnesses/perp-cohort-stats/cmd/script/source_pacifica.go b/harnesses/perp-cohort-stats/cmd/script/source_pacifica.go new file mode 100644 index 00000000..001cccac --- /dev/null +++ b/harnesses/perp-cohort-stats/cmd/script/source_pacifica.go @@ -0,0 +1,113 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "time" +) + +// PacificaNativeSource calls the public Pacifica prices endpoint: +// +// GET https://api.pacifica.fi/api/v1/info/prices +// +// Response shape: `{ "success": true, "data": [{...}, ...] }` with one +// row per market (~70 perps). Each row exposes: +// +// symbol (e.g. "BTC") +// mark (USD, string) +// open_interest (BASE units, string) +// volume_24h (USD notional 24h, string; verified live against +// BTC: 431M matches CG perp volume for the venue) +// +// Derived metrics: +// +// volume_24h_usd = sum(volume_24h) +// oi_usd = sum(open_interest * mark) +// active_markets = count(rows with mark > 0) +// top_market_volume_24h_usd = max(volume_24h) +type PacificaNativeSource struct { + client *http.Client +} + +func NewPacificaNativeSource() *PacificaNativeSource { + return &PacificaNativeSource{ + client: &http.Client{Timeout: 15 * time.Second}, + } +} + +func (s *PacificaNativeSource) Name() string { return srcPacificaNative } + +type pacificaRow struct { + Symbol string `json:"symbol"` + Mark string `json:"mark"` + OpenInterest string `json:"open_interest"` + Volume24h string `json:"volume_24h"` +} + +type pacificaResponse struct { + Success bool `json:"success"` + Data []pacificaRow `json:"data"` +} + +func (s *PacificaNativeSource) Fetch() (*SourceResult, error) { + res := newSourceResult() + venue := "pacifica" + + body, err := s.get("https://api.pacifica.fi/api/v1/info/prices") + if err != nil { + perpCohortFetchErrors.WithLabelValues(venue, srcPacificaNative, classifyError(err.Error())).Inc() + fmt.Printf("[perp-cohort][%s][%s] err: %v\n", venue, srcPacificaNative, err) + return res, nil + } + + var parsed pacificaResponse + if err := json.Unmarshal(body, &parsed); err != nil { + perpCohortFetchErrors.WithLabelValues(venue, srcPacificaNative, "parse").Inc() + fmt.Printf("[perp-cohort][%s][%s] err parse: %v\n", venue, srcPacificaNative, err) + return res, nil + } + + var volSum, oiSum, topVol float64 + var active int + for _, r := range parsed.Data { + mark, _ := strconv.ParseFloat(r.Mark, 64) + if mark <= 0 { + continue + } + active++ + v, _ := strconv.ParseFloat(r.Volume24h, 64) + volSum += v + if v > topVol { + topVol = v + } + oiBase, _ := strconv.ParseFloat(r.OpenInterest, 64) + oiSum += oiBase * mark + } + + res.SetIfPositive(venue, mVolume24h, volSum) + res.SetIfPositive(venue, mOI, oiSum) + res.SetIfPositive(venue, mActiveMarkets, float64(active)) + res.SetIfPositive(venue, mTopVol24h, topVol) + fmt.Printf("[perp-cohort][%s][%s] ok: active=%d vol24h=%.0f oi=%.0f top24h=%.0f\n", + venue, srcPacificaNative, active, volSum, oiSum, topVol) + return res, nil +} + +func (s *PacificaNativeSource) get(url string) ([]byte, error) { + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("User-Agent", "OpenChainBench-PerpCohort/1.0 contact@mobula.io") + req.Header.Set("Accept", "application/json") + resp, err := s.client.Do(req) + if err != nil { + return nil, fmt.Errorf("request_error: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return nil, fmt.Errorf("status_%d: %s", resp.StatusCode, truncate(string(body), 200)) + } + return body, nil +} diff --git a/harnesses/perp-cohort-stats/cmd/script/source_paradex.go b/harnesses/perp-cohort-stats/cmd/script/source_paradex.go new file mode 100644 index 00000000..e4e959f4 --- /dev/null +++ b/harnesses/perp-cohort-stats/cmd/script/source_paradex.go @@ -0,0 +1,116 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" +) + +// ParadexNativeSource hits two endpoints: +// +// GET https://api.prod.paradex.trade/v1/markets (catalog) +// GET https://api.prod.paradex.trade/v1/markets/summary?market=ALL (per-market live) +// +// The catalog returns ~1.5k instruments (PERP + OPTION); we filter to +// asset_kind == "PERP". The summary call returns the same superset +// keyed by symbol; we keep rows whose symbol ends in `-USD-PERP`. The +// `total_volume` field is a lifetime number; the 24h field is +// `volume_24h`. +// +// Derived metrics: +// +// volume_24h_usd = sum(volume_24h) across perp markets +// oi_usd = sum(open_interest * mark_price) +// active_markets = count(perp rows in summary that have a mark_price) +// top_market_volume_24h_usd = max(volume_24h) +type ParadexNativeSource struct { + client *http.Client +} + +func NewParadexNativeSource() *ParadexNativeSource { + return &ParadexNativeSource{ + client: &http.Client{Timeout: 15 * time.Second}, + } +} + +func (s *ParadexNativeSource) Name() string { return srcParadexNative } + +type paradexSummaryRow struct { + Symbol string `json:"symbol"` + MarkPrice string `json:"mark_price"` + OpenInterest string `json:"open_interest"` + Volume24h string `json:"volume_24h"` +} + +type paradexSummaryResponse struct { + Results []paradexSummaryRow `json:"results"` +} + +func (s *ParadexNativeSource) Fetch() (*SourceResult, error) { + res := newSourceResult() + venue := "paradex" + + body, err := s.get("https://api.prod.paradex.trade/v1/markets/summary?market=ALL") + if err != nil { + perpCohortFetchErrors.WithLabelValues(venue, srcParadexNative, classifyError(err.Error())).Inc() + fmt.Printf("[perp-cohort][%s][%s] err: %v\n", venue, srcParadexNative, err) + return res, nil + } + + var parsed paradexSummaryResponse + if err := json.Unmarshal(body, &parsed); err != nil { + perpCohortFetchErrors.WithLabelValues(venue, srcParadexNative, "parse").Inc() + fmt.Printf("[perp-cohort][%s][%s] err parse: %v\n", venue, srcParadexNative, err) + return res, nil + } + + var volSum, oiSum, topVol float64 + var active int + for _, m := range parsed.Results { + // Symbol convention: `-USD-PERP` for linear perps. Anything + // with a date suffix (`-26JUN26-`) is an option and is skipped. + if !strings.HasSuffix(m.Symbol, "-USD-PERP") { + continue + } + mark, _ := strconv.ParseFloat(m.MarkPrice, 64) + if mark <= 0 { + continue + } + active++ + v, _ := strconv.ParseFloat(m.Volume24h, 64) + volSum += v + if v > topVol { + topVol = v + } + oi, _ := strconv.ParseFloat(m.OpenInterest, 64) + oiSum += oi * mark + } + + res.SetIfPositive(venue, mVolume24h, volSum) + res.SetIfPositive(venue, mOI, oiSum) + res.SetIfPositive(venue, mActiveMarkets, float64(active)) + res.SetIfPositive(venue, mTopVol24h, topVol) + fmt.Printf("[perp-cohort][%s][%s] ok: active=%d vol24h=%.0f oi=%.0f top24h=%.0f\n", + venue, srcParadexNative, active, volSum, oiSum, topVol) + return res, nil +} + +func (s *ParadexNativeSource) get(url string) ([]byte, error) { + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("User-Agent", "OpenChainBench-PerpCohort/1.0 contact@mobula.io") + req.Header.Set("Accept", "application/json") + resp, err := s.client.Do(req) + if err != nil { + return nil, fmt.Errorf("request_error: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return nil, fmt.Errorf("status_%d: %s", resp.StatusCode, truncate(string(body), 200)) + } + return body, nil +} diff --git a/harnesses/perp-cohort-stats/cmd/script/source_variational.go b/harnesses/perp-cohort-stats/cmd/script/source_variational.go new file mode 100644 index 00000000..d93dca1d --- /dev/null +++ b/harnesses/perp-cohort-stats/cmd/script/source_variational.go @@ -0,0 +1,33 @@ +package main + +import ( + "fmt" +) + +// VariationalNativeSource is a visibility-only stub. The public +// hostname `api.variational.io` fails to resolve (NXDOMAIN) as of +// Sprint 3, and Variational publishes no documented public REST +// alternative for cohort-level stats. The product is RFQ-style and +// most data sits behind authenticated GraphQL. +// +// DefiLlama tracks the venue under the slug "variational" with live +// total24h/total30d/openInterest values, so the router uses DefiLlama +// as the primary source. This stub exists so the per-venue health +// gauge has an honest "native = blocked" signal and the fetch-errors +// counter records the outage with error_type=public_api_blocked. +type VariationalNativeSource struct{} + +func NewVariationalNativeSource() *VariationalNativeSource { + return &VariationalNativeSource{} +} + +func (s *VariationalNativeSource) Name() string { return srcVariationalNative } + +func (s *VariationalNativeSource) Fetch() (*SourceResult, error) { + res := newSourceResult() + venue := "variational" + perpCohortFetchErrors.WithLabelValues(venue, srcVariationalNative, "public_api_blocked").Inc() + fmt.Printf("[perp-cohort][%s][%s] skipped: api.variational.io NXDOMAIN; see source_variational.go header\n", + venue, srcVariationalNative) + return res, nil +} diff --git a/harnesses/perp-cohort-stats/cmd/script/source_vertex.go b/harnesses/perp-cohort-stats/cmd/script/source_vertex.go new file mode 100644 index 00000000..429f1ccd --- /dev/null +++ b/harnesses/perp-cohort-stats/cmd/script/source_vertex.go @@ -0,0 +1,307 @@ +package main + +import ( + "bytes" + "compress/gzip" + "encoding/json" + "fmt" + "io" + "math/big" + "net/http" + "strings" + "time" +) + +// VertexNativeSource hits the Vertex (now rebranded "Nado") indexer: +// +// GET https://gateway.prod.nado.xyz/v1/symbols (perp universe) +// POST https://archive.prod.nado.xyz/v1 (market_snapshots) +// +// The /symbols call returns the full product catalog typed as +// `spot` or `perp`. We keep entries with type=="perp" and +// trading_status=="live" to build the perp universe. +// +// The archive market_snapshots call returns cumulative metrics +// keyed by product_id. Because the volume field is cumulative since +// inception, we request two snapshots 24h apart and diff them to get +// the 24h volume. Open interest is reported at the snapshot timestamp +// (BASE units) and converted to USD using the latest oracle_price. +// All fields are stringified x18-scaled big integers; we cast through +// big.Int and big.Float to preserve precision. +// +// For 30d aggregates we issue a second market_snapshots call with +// 31 daily granules and sum the successive cumulative_volumes / +// cumulative_(taker+maker)_fees deltas per market. DefiLlama's +// vertex-perps page returns null for both metrics so the native path +// is the only signal we have for vol30d / fees30d. +// +// Derived metrics: +// +// volume_24h_usd = sum(now.cumulative_volumes[pid] - prior.cumulative_volumes[pid]) / 1e18 +// volume_30d_usd = sum_over_markets(sum_over_30_daily_deltas(cumulative_volumes)) / 1e18 +// oi_usd = sum(now.open_interests[pid]) / 1e18 (the field is already quoted in USD) +// fees_30d_usd = sum_over_markets(sum_over_30_daily_deltas(cumulative_taker_fees + cumulative_maker_fees)) / 1e18 +// active_markets = count(perp products with trading_status == "live") +// top_market_volume_24h_usd = max single-product 24h volume +type VertexNativeSource struct { + client *http.Client +} + +func NewVertexNativeSource() *VertexNativeSource { + return &VertexNativeSource{ + client: &http.Client{Timeout: 15 * time.Second}, + } +} + +func (s *VertexNativeSource) Name() string { return srcVertexNative } + +type vertexSymbol struct { + Type string `json:"type"` + ProductID int `json:"product_id"` + Symbol string `json:"symbol"` + TradingStatus string `json:"trading_status"` +} + +type vertexSnapshot struct { + Timestamp int64 `json:"timestamp"` + CumulativeVolumes map[string]string `json:"cumulative_volumes"` + OpenInterests map[string]string `json:"open_interests"` + CumulativeTakerFees map[string]string `json:"cumulative_taker_fees"` + CumulativeMakerFees map[string]string `json:"cumulative_maker_fees"` +} + +type vertexSnapshotResponse struct { + Snapshots []vertexSnapshot `json:"snapshots"` +} + +func (s *VertexNativeSource) Fetch() (*SourceResult, error) { + res := newSourceResult() + venue := "vertex" + + // Step 1: enumerate live perp products. + symbolsBody, err := s.get("https://gateway.prod.nado.xyz/v1/symbols") + if err != nil { + perpCohortFetchErrors.WithLabelValues(venue, srcVertexNative, classifyError(err.Error())).Inc() + fmt.Printf("[perp-cohort][%s][%s] err symbols: %v\n", venue, srcVertexNative, err) + return res, nil + } + var symbols []vertexSymbol + if err := json.Unmarshal(symbolsBody, &symbols); err != nil { + perpCohortFetchErrors.WithLabelValues(venue, srcVertexNative, "parse").Inc() + fmt.Printf("[perp-cohort][%s][%s] err parse symbols: %v\n", venue, srcVertexNative, err) + return res, nil + } + var perpIDs []int + for _, sym := range symbols { + if sym.Type != "perp" || sym.TradingStatus != "live" { + continue + } + perpIDs = append(perpIDs, sym.ProductID) + } + if len(perpIDs) == 0 { + return res, nil + } + + // Step 2: fetch 2 snapshots 24h apart; diff cumulative volumes. + body, err := s.postSnapshots(perpIDs) + if err != nil { + perpCohortFetchErrors.WithLabelValues(venue, srcVertexNative, classifyError(err.Error())).Inc() + fmt.Printf("[perp-cohort][%s][%s] err archive: %v\n", venue, srcVertexNative, err) + // Still publish active_markets from the symbols call. + res.SetIfPositive(venue, mActiveMarkets, float64(len(perpIDs))) + return res, nil + } + var parsed vertexSnapshotResponse + if err := json.Unmarshal(body, &parsed); err != nil { + perpCohortFetchErrors.WithLabelValues(venue, srcVertexNative, "parse").Inc() + fmt.Printf("[perp-cohort][%s][%s] err parse archive: %v\n", venue, srcVertexNative, err) + res.SetIfPositive(venue, mActiveMarkets, float64(len(perpIDs))) + return res, nil + } + if len(parsed.Snapshots) < 2 { + fmt.Printf("[perp-cohort][%s][%s] only %d snapshots returned, need 2 for volume diff\n", + venue, srcVertexNative, len(parsed.Snapshots)) + res.SetIfPositive(venue, mActiveMarkets, float64(len(perpIDs))) + return res, nil + } + + now := parsed.Snapshots[0] + prior := parsed.Snapshots[1] + + var volSum, oiSum, topVol float64 + for _, pid := range perpIDs { + key := fmt.Sprintf("%d", pid) + // 24h volume = (now - prior) cumulative, dropped to USD. + vDelta := subX18ToFloat(now.CumulativeVolumes[key], prior.CumulativeVolumes[key]) + if vDelta > 0 { + volSum += vDelta + if vDelta > topVol { + topVol = vDelta + } + } + // OI is already in quoted USDC (the SDK calls it `openInterestsQuote`) + // at 18 decimals, so we just remove the 1e18 scale to get USD. + oiSum += removeX18(now.OpenInterests[key]) + } + + res.SetIfPositive(venue, mVolume24h, volSum) + res.SetIfPositive(venue, mOI, oiSum) + res.SetIfPositive(venue, mActiveMarkets, float64(len(perpIDs))) + res.SetIfPositive(venue, mTopVol24h, topVol) + + // Step 3: 30d aggregates. Separate archive call with 31 daily granules + // so we can diff successive cumulatives. We tolerate a partial response + // (e.g. archive returns 12 of the 31 snapshots): the daily-delta sum + // still represents the realized trailing window we observed, and + // SetIfPositive keeps us from overwriting carry-forward on a hard miss. + vol30dSum, fees30dSum, snapCount, ok30d := s.fetch30dAggregates(perpIDs, venue) + if ok30d { + res.SetIfPositive(venue, mVolume30d, vol30dSum) + res.SetIfPositive(venue, mFees30d, fees30dSum) + } + + fmt.Printf("[perp-cohort][%s][%s] ok: perps=%d vol24h=%.0f oi=%.0f top24h=%.0f vol30d=%.0f fees30d=%.0f snaps30d=%d\n", + venue, srcVertexNative, len(perpIDs), volSum, oiSum, topVol, vol30dSum, fees30dSum, snapCount) + return res, nil +} + +// fetch30dAggregates issues a second market_snapshots call for the 30d +// window. Returns (vol30d, fees30d, snapshotsSeen, ok). ok=false means +// the call or parse failed end to end; partial snapshot counts still +// return ok=true so the caller can publish what it has. +func (s *VertexNativeSource) fetch30dAggregates(productIDs []int, venue string) (float64, float64, int, bool) { + body, err := s.postSnapshotsWindow(productIDs, 31, 86400) + if err != nil { + perpCohortFetchErrors.WithLabelValues(venue, srcVertexNative, classifyError(err.Error())).Inc() + fmt.Printf("[perp-cohort][%s][%s] err archive(30d): %v\n", venue, srcVertexNative, err) + return 0, 0, 0, false + } + var parsed vertexSnapshotResponse + if err := json.Unmarshal(body, &parsed); err != nil { + perpCohortFetchErrors.WithLabelValues(venue, srcVertexNative, "parse").Inc() + fmt.Printf("[perp-cohort][%s][%s] err parse archive(30d): %v\n", venue, srcVertexNative, err) + return 0, 0, 0, false + } + if len(parsed.Snapshots) < 2 { + fmt.Printf("[perp-cohort][%s][%s] only %d snapshots returned for 30d window, need >=2\n", + venue, srcVertexNative, len(parsed.Snapshots)) + return 0, 0, len(parsed.Snapshots), false + } + + // Archive returns snapshots ordered newest-first. We walk pairs + // (snapshots[i], snapshots[i+1]) and sum each positive delta into + // the per-market accumulator, then sum across markets. + var vol30d, fees30d float64 + for i := 0; i < len(parsed.Snapshots)-1; i++ { + newer := parsed.Snapshots[i] + older := parsed.Snapshots[i+1] + for _, pid := range productIDs { + key := fmt.Sprintf("%d", pid) + vol30d += subX18ToFloat(newer.CumulativeVolumes[key], older.CumulativeVolumes[key]) + takerDelta := subX18ToFloat(newer.CumulativeTakerFees[key], older.CumulativeTakerFees[key]) + makerDelta := subX18ToFloat(newer.CumulativeMakerFees[key], older.CumulativeMakerFees[key]) + fees30d += takerDelta + makerDelta + } + } + return vol30d, fees30d, len(parsed.Snapshots), true +} + +func (s *VertexNativeSource) postSnapshots(productIDs []int) ([]byte, error) { + return s.postSnapshotsWindow(productIDs, 2, 86400) +} + +// postSnapshotsWindow issues a market_snapshots call with the given +// granule count and granularity (seconds). The archive endpoint caps +// `count` at ~31 in practice; callers should not exceed that. +func (s *VertexNativeSource) postSnapshotsWindow(productIDs []int, count, granularity int) ([]byte, error) { + body := map[string]any{ + "market_snapshots": map[string]any{ + "interval": map[string]any{ + "count": count, + "granularity": granularity, + }, + "product_ids": productIDs, + }, + } + raw, err := json.Marshal(body) + if err != nil { + return nil, err + } + req, _ := http.NewRequest("POST", "https://archive.prod.nado.xyz/v1", bytes.NewReader(raw)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "OpenChainBench-PerpCohort/1.0 contact@mobula.io") + req.Header.Set("Accept", "application/json") + // The archive gateway rejects clients that don't advertise gzip. + req.Header.Set("Accept-Encoding", "gzip") + + resp, err := s.client.Do(req) + if err != nil { + return nil, fmt.Errorf("request_error: %w", err) + } + defer resp.Body.Close() + return readMaybeGzip(resp) +} + +func (s *VertexNativeSource) get(url string) ([]byte, error) { + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("User-Agent", "OpenChainBench-PerpCohort/1.0 contact@mobula.io") + req.Header.Set("Accept", "application/json") + req.Header.Set("Accept-Encoding", "gzip") + resp, err := s.client.Do(req) + if err != nil { + return nil, fmt.Errorf("request_error: %w", err) + } + defer resp.Body.Close() + return readMaybeGzip(resp) +} + +func readMaybeGzip(resp *http.Response) ([]byte, error) { + var reader io.ReadCloser = resp.Body + if strings.EqualFold(resp.Header.Get("Content-Encoding"), "gzip") { + gz, err := gzip.NewReader(resp.Body) + if err != nil { + return nil, fmt.Errorf("gzip: %w", err) + } + defer gz.Close() + reader = io.NopCloser(gz) + } + body, _ := io.ReadAll(reader) + if resp.StatusCode != 200 { + return nil, fmt.Errorf("status_%d: %s", resp.StatusCode, truncate(string(body), 200)) + } + return body, nil +} + +// subX18ToFloat returns float64((a - b) / 1e18). Inputs are decimal +// integer strings of arbitrary length, so we use big.Int math first +// and only down-cast at the very end. Negative deltas (snapshot order +// reversed, late-arriving rows) round to 0 since the caller treats +// SetIfPositive as the publish gate. +func subX18ToFloat(a, b string) float64 { + ai, aok := new(big.Int).SetString(a, 10) + bi, bok := new(big.Int).SetString(b, 10) + if !aok || !bok { + return 0 + } + delta := new(big.Int).Sub(ai, bi) + if delta.Sign() <= 0 { + return 0 + } + f := new(big.Float).SetInt(delta) + f.Quo(f, big.NewFloat(1e18)) + v, _ := f.Float64() + return v +} + +// removeX18 returns float64(a / 1e18). Used for fields the indexer +// publishes at the standard Nado 18-decimal scale. +func removeX18(a string) float64 { + ai, ok := new(big.Int).SetString(a, 10) + if !ok { + return 0 + } + f := new(big.Float).SetInt(ai) + f.Quo(f, big.NewFloat(1e18)) + v, _ := f.Float64() + return v +} diff --git a/harnesses/perp-cohort-stats/go.mod b/harnesses/perp-cohort-stats/go.mod new file mode 100644 index 00000000..dacc5b7a --- /dev/null +++ b/harnesses/perp-cohort-stats/go.mod @@ -0,0 +1,17 @@ +module github.com/mobula/perp-cohort-stats + +go 1.24 + +require github.com/prometheus/client_golang v1.20.5 + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/klauspost/compress v1.17.9 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // 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 + golang.org/x/sys v0.22.0 // indirect + google.golang.org/protobuf v1.34.2 // indirect +) diff --git a/harnesses/perp-cohort-stats/go.sum b/harnesses/perp-cohort-stats/go.sum new file mode 100644 index 00000000..d5318cf8 --- /dev/null +++ b/harnesses/perp-cohort-stats/go.sum @@ -0,0 +1,24 @@ +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/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +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/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/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= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= +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= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= diff --git a/harnesses/pm-cohort-stats/.env.example b/harnesses/pm-cohort-stats/.env.example new file mode 100644 index 00000000..c4bc9d74 --- /dev/null +++ b/harnesses/pm-cohort-stats/.env.example @@ -0,0 +1,12 @@ +# Kalshi API credentials (optional: enables the Kalshi source) +KALSHI_KEY_ID= +KALSHI_PRIVATE_KEY= +# Per-source refresh intervals in minutes (all optional, sane defaults) +POLYMARKET_REFRESH_MINUTES= +KALSHI_REFRESH_MINUTES= +LIMITLESS_REFRESH_MINUTES= +MANIFOLD_REFRESH_MINUTES= +MYRIAD_REFRESH_MINUTES= +DEFILLAMA_REFRESH_MINUTES= +# Manifold play-money conversion rate (optional) +MANIFOLD_MANA_USD_RATE= diff --git a/harnesses/pm-cohort-stats/Dockerfile b/harnesses/pm-cohort-stats/Dockerfile new file mode 100644 index 00000000..be0ef002 --- /dev/null +++ b/harnesses/pm-cohort-stats/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 || true + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux go build -o /app/pm-cohort-stats ./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/pm-cohort-stats /app/pm-cohort-stats + +EXPOSE 2112 + +CMD ["/app/pm-cohort-stats"] diff --git a/harnesses/pm-cohort-stats/README.md b/harnesses/pm-cohort-stats/README.md new file mode 100644 index 00000000..3ad06927 --- /dev/null +++ b/harnesses/pm-cohort-stats/README.md @@ -0,0 +1,88 @@ +# pm-cohort-stats + +Per-venue prediction-market cohort exporter for the OCB PM benches and the +`/benches/pm-*` product pages. + +## What it does + +Polls Polymarket gamma-api, Kalshi REST, and DefiLlama `/protocols` on a +fixed cadence, computes the OCB-canonical per-venue cohort metrics, and +exposes Prometheus gauges on `:2112/metrics` that the OCB site reads from +on every SSR render of the PM pages. + +## Sources & gauges + +| Gauge | Source | Cadence | +|---|---|---| +| `pm_venue_volume_30d_usd{venue}` | Polymarket gamma `/markets` `volume1mo`, Kalshi `/markets` `volume`, DefiLlama `/protocols` | 5 min (polymarket, kalshi), 15 min (defillama) | +| `pm_venue_volume_24h_usd{venue}` | Polymarket `volume24hr`, Kalshi `volume_24h` | 5 min | +| `pm_venue_open_interest_usd{venue}` | Polymarket `openInterest`, Kalshi `open_interest * last_price`, DefiLlama TVL fallback | 5 min (polymarket, kalshi), 15 min (defillama) | +| `pm_venue_active_markets{venue}` | Count of open markets from `/markets` | 5 min | +| `pm_venue_top_market_volume_24h_usd{venue}` | `max(volume24hr)` across active markets | 5 min | +| `pm_venue_markets_above_1m{venue}` | Count of markets with all-time `volume >= 1m` | 5 min | + +Plus observability: +- `pm_cohort_stats_last_refresh_timestamp_seconds{venue, source}` +- `pm_cohort_stats_fetch_latency_milliseconds{venue, source}` +- `pm_cohort_stats_fetch_errors_total{venue, source, error_type}` +- `pm_cohort_stats_last_tick_unix` + +## Venue registry + +The set of venues (slug, name, onchain/offchain, settlement chain) is +hardcoded in `cmd/script/registry.go`. It MUST mirror the OCB site's PM +venue registry. Adding a new venue: + +1. Append to `Registry` in `cmd/script/registry.go` +2. Append on the OCB site +3. Redeploy both + +Today's set: + +| Slug | Type | Chain | Source | +|---|---|---|---| +| polymarket | onchain | polygon | gamma-api | +| kalshi | offchain | (n/a) | Kalshi REST | +| limitless | onchain | base | DefiLlama fallback | +| manifold | offchain | (n/a) | DefiLlama fallback | +| myriad | offchain | (n/a) | DefiLlama fallback | + +## Env vars + +| Var | Default | Required | +|---|---|---| +| `POLYMARKET_REFRESH_MINUTES` | `5` | Optional override. | +| `KALSHI_REFRESH_MINUTES` | `5` | Optional override. | +| `DEFILLAMA_REFRESH_MINUTES` | `15` | Optional override. | + +No API keys required: every source is public read-only. + +## Port + +Hardcoded `:2112` per the OCB harness convention. The shared Prom-gateway +on Railway is configured to scrape `:2112` from every OCB harness. Do not +listen on `$PORT`; Railway sets that env var for its proxy layer and the +harness ignores it. + +## Graceful degradation + +- Polymarket gamma 5xx / 429 -> the page break is taken, gauges left at + their previous value (Prom carry-forward), a fetch-errors counter is + incremented with the bucketed `error_type`. +- Kalshi REST 5xx / 429 -> same: skip this tick, next ticker call retries. +- DefiLlama `/protocols` lookup miss for a venue -> counter incremented + with `error_type="not_tracked"`; gauges left untouched. +- One source going down does not poison any other source: each runs on + its own goroutine loop with its own ticker. + +## Local run + +```bash +POLYMARKET_REFRESH_MINUTES=5 \ +KALSHI_REFRESH_MINUTES=5 \ +DEFILLAMA_REFRESH_MINUTES=15 \ +go run ./cmd/script + +# In another terminal: +curl -s http://localhost:2112/metrics | grep '^pm_venue_' | head +``` diff --git a/harnesses/pm-cohort-stats/cmd/script/config.go b/harnesses/pm-cohort-stats/cmd/script/config.go new file mode 100644 index 00000000..bfda0d25 --- /dev/null +++ b/harnesses/pm-cohort-stats/cmd/script/config.go @@ -0,0 +1,128 @@ +package main + +import ( + "fmt" + "os" + "strconv" + "time" +) + +// Config holds runtime knobs. Set via env vars on Railway. +type Config struct { + // How often Polymarket gamma-api is polled. 5 min is the conservative + // default: gamma-api sits behind Cloudflare with a generous global cap + // (~4000 req/10s) and our paginated /markets pull is ~3 requests per + // tick. 12 ticks/h * ~3 calls = ~36 req/h, well under any sane ceiling. + PolymarketRefreshInterval time.Duration + + // Kalshi REST tick. 5 min keeps the cohort numbers fresh; their public + // /markets endpoint is unauthenticated for read and paginates via a + // cursor. We paginate until the cursor empties or we hit a safety cap. + KalshiRefreshInterval time.Duration + + // DefiLlama tick. Slower because the /protocols list moves on daily + // cadence; 15 min is fine and keeps the courtesy budget low. Used as + // the canonical fallback aggregate for Limitless and Manifold. + // Myriad has its own native fetcher (see myriad.go) and is no longer + // fed from DefiLlama. + DefillamaRefreshInterval time.Duration + + // Limitless tick. Public api.limitless.exchange does not advertise + // rate-limit headers and sits behind Cloudflare; 5 min keeps the + // courtesy budget low. A full walk of /markets/active at page size 25 + // is ~40 pages for the live universe (~1000 markets) with a 200ms + // inter-page delay, so one tick is bounded under ~10 s. Only fills + // the cohort gauges the public REST cleanly supports (active count + + // two lifetime proxies); OI for Limitless still comes from DefiLlama + // TVL in defillama.go. + LimitlessRefreshInterval time.Duration + + // Myriad tick. Public api-v2.myriadprotocol.com is rate-limited at + // 30 req / 10s unauthenticated, silently enforced. One tick spends + // ~8 paginated requests self-throttled at 1 req/s, so 5 min stays + // comfortably under budget. + MyriadRefreshInterval time.Duration + + // Manifold tick. Public api.manifold.markets is generous (500 req/min + // per IP) and the open universe today (~1.1k markets) fits in 2 pages + // of 1000, so 5 min is comfortably under budget (~24 req/h). + ManifoldRefreshInterval time.Duration + + // ManifoldManaUsdRate is the conversion factor applied to Manifold's + // play-money "mana" balances when publishing the USD-denominated + // pm_venue_*_usd gauges. There is NO official mana->USD exchange + // rate: Manifold's CASH sweepstakes token surface is currently empty, + // so we anchor on the legacy charity-donation rate of 1000 mana = $1 + // (0.001 USD per mana). This is a DISCLOSED convention, not a market + // price; set MANIFOLD_MANA_USD_RATE to override. + ManifoldManaUsdRate float64 + + // Kalshi authenticated API credentials. The PUBLIC /markets endpoint + // only surfaces auto-generated derivative tickers with negligible + // volume, so the 24h gauge stays structurally near zero. With a key + // pair we instead aggregate the live /markets/trades feed (which + // carries every fill across the venue including the headline event + // markets) and scale a 1h sample to 24h. KALSHI_KEY_ID is the public + // access-key identifier shown in the Kalshi dashboard; + // KALSHI_PRIVATE_KEY is the PEM-encoded RSA private key Kalshi gave + // at key-creation time. Both empty = falls back to public-only mode. + KalshiKeyID string + KalshiPrivateKey string +} + +func loadConfig() *Config { + c := &Config{ + PolymarketRefreshInterval: 5 * time.Minute, + KalshiRefreshInterval: 5 * time.Minute, + DefillamaRefreshInterval: 15 * time.Minute, + LimitlessRefreshInterval: 5 * time.Minute, + MyriadRefreshInterval: 5 * time.Minute, + ManifoldRefreshInterval: 5 * time.Minute, + ManifoldManaUsdRate: 0.001, + } + + if v := os.Getenv("POLYMARKET_REFRESH_MINUTES"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + c.PolymarketRefreshInterval = time.Duration(n) * time.Minute + } + } + if v := os.Getenv("KALSHI_REFRESH_MINUTES"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + c.KalshiRefreshInterval = time.Duration(n) * time.Minute + } + } + if v := os.Getenv("DEFILLAMA_REFRESH_MINUTES"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + c.DefillamaRefreshInterval = time.Duration(n) * time.Minute + } + } + if v := os.Getenv("LIMITLESS_REFRESH_MINUTES"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + c.LimitlessRefreshInterval = time.Duration(n) * time.Minute + } + } + if v := os.Getenv("MYRIAD_REFRESH_MINUTES"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + c.MyriadRefreshInterval = time.Duration(n) * time.Minute + } + } + if v := os.Getenv("MANIFOLD_REFRESH_MINUTES"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + c.ManifoldRefreshInterval = time.Duration(n) * time.Minute + } + } + if v := os.Getenv("MANIFOLD_MANA_USD_RATE"); v != "" { + if f, err := strconv.ParseFloat(v, 64); err == nil && f > 0 { + c.ManifoldManaUsdRate = f + } + } + + c.KalshiKeyID = os.Getenv("KALSHI_KEY_ID") + c.KalshiPrivateKey = os.Getenv("KALSHI_PRIVATE_KEY") + + fmt.Printf("Config: venues=%d, polymarket_every=%v, kalshi_every=%v, defillama_every=%v, limitless_every=%v, myriad_every=%v, manifold_every=%v, manifold_mana_usd=%v, kalshi_auth=%v\n", + len(Registry), c.PolymarketRefreshInterval, c.KalshiRefreshInterval, c.DefillamaRefreshInterval, + c.LimitlessRefreshInterval, c.MyriadRefreshInterval, c.ManifoldRefreshInterval, c.ManifoldManaUsdRate, + c.KalshiKeyID != "" && c.KalshiPrivateKey != "") + return c +} diff --git a/harnesses/pm-cohort-stats/cmd/script/defillama.go b/harnesses/pm-cohort-stats/cmd/script/defillama.go new file mode 100644 index 00000000..4a7bbc5e --- /dev/null +++ b/harnesses/pm-cohort-stats/cmd/script/defillama.go @@ -0,0 +1,167 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +// DefiLlama is the fallback aggregate source for venues we have no +// dedicated public API for, plus a TVL-as-OI fallback for Polymarket +// (the gamma /markets openInterest field has been deprecated and +// returns 0 across the board; DefiLlama's protocol TVL is the closest +// public OI proxy). We hit the /protocols list once per tick, filter +// rows with category=="Prediction Market", and map by canonical name +// to each venue. The mapping is tolerant of casing and minor +// formatting variation. +// +// Myriad was previously fed by DefiLlama TVL (~$449k open-interest +// proxy), but now has a dedicated native fetcher (see myriad.go) whose +// gauges are authoritative; it is intentionally absent from llamaNames. +// +// DefiLlama publishes per-protocol fields we surface: +// tvl -> pm_venue_open_interest_usd (proxy: TVL is the closest public OI proxy for an on/off-chain PM) +// volume_24h or change_1d -> pm_venue_volume_24h_usd (best effort; many PM protocols do not report) +// volume_30d -> pm_venue_volume_30d_usd (best effort) +// +// active_markets, top_market_volume, markets_above_1m are NOT available +// from /protocols; those gauges are left untouched for these venues. The +// OCB page knows to render only the cards that have data. + +const ( + defillamaBase = "https://api.llama.fi" + defillamaUA = "OCB-pm-cohort-stats/1.0" + defillamaPMCat = "Prediction Market" +) + +var httpClientDefillama = &http.Client{Timeout: 20 * time.Second} + +// llamaNames maps an OCB venue slug to the canonical name DefiLlama uses +// in /protocols. Verified live by reading /protocols and matching the +// `name` field exactly. Manifold is intentionally omitted: as of today +// it is not listed under the Prediction Market category on DefiLlama, +// so the cohort gauges for Manifold stay empty until a dedicated public +// fetcher is added (Manifold has its own /v0/markets REST endpoint; out +// of scope for the first cut of this harness). Myriad is intentionally +// omitted now that the native /markets fetcher in myriad.go publishes +// authoritative gauges from the venue's own API. +var llamaNames = map[string]string{ + "limitless": "Limitless Exchange", + "polymarket": "Polymarket International", +} + +type llamaProtocol struct { + Name string `json:"name"` + Category string `json:"category"` + TVL float64 `json:"tvl"` + Change1d float64 `json:"change_1d"` +} + +func fetchAllDefillama() { + // One shared list request per tick, then per-venue extraction in + // parallel. The /protocols payload is ~2 MB and cached server-side, + // so we deliberately avoid issuing one request per venue. + start := time.Now() + url := fmt.Sprintf("%s/protocols", defillamaBase) + body, err := getJSONDefillama(httpClientDefillama, url) + latency := float64(time.Since(start).Milliseconds()) + if err != nil { + for slug := range llamaNames { + pmCohortStatsFetchLatencyMs.WithLabelValues(slug, "defillama").Set(latency) + pmCohortStatsFetchErrors.WithLabelValues(slug, "defillama", classifyError(err.Error())).Inc() + } + fmt.Printf("[defillama] list error: %v\n", err) + return + } + + var all []llamaProtocol + if err := json.Unmarshal(body, &all); err != nil { + for slug := range llamaNames { + pmCohortStatsFetchLatencyMs.WithLabelValues(slug, "defillama").Set(latency) + pmCohortStatsFetchErrors.WithLabelValues(slug, "defillama", "parse").Inc() + } + fmt.Printf("[defillama] list parse error: %v\n", err) + return + } + + // Index by normalized name across PM-category rows for fuzzy lookup. + idx := map[string]llamaProtocol{} + for _, p := range all { + if p.Category != defillamaPMCat { + continue + } + idx[normalizeName(p.Name)] = p + } + + for slug, llamaName := range llamaNames { + v := VenueBySlug(slug) + if v == nil { + continue + } + pmCohortStatsFetchLatencyMs.WithLabelValues(slug, "defillama").Set(latency) + row, ok := idx[normalizeName(llamaName)] + if !ok { + pmCohortStatsFetchErrors.WithLabelValues(slug, "defillama", "not_tracked").Inc() + fmt.Printf("[defillama][%s] not found in PM category (looked for %q)\n", slug, llamaName) + continue + } + // TVL is the only consistently populated USD field; use it as the + // open-interest proxy for these aggregate-only venues. + if row.TVL > 0 { + pmVenueOpenInterestUsd.WithLabelValues(slug).Set(row.TVL) + } + // /protocols does not expose volume; we leave volume gauges + // untouched (Prom carry-forward keeps the last successful value + // if a Polymarket-style fetcher ever publishes; otherwise the + // gauge stays absent and the site renders only the cards we have). + pmCohortStatsLastRefresh.WithLabelValues(slug, "defillama").Set(float64(time.Now().Unix())) + fmt.Printf("[defillama][%s] tvl=%.0f change_1d=%.2f%%\n", slug, row.TVL, row.Change1d) + } + + pmCohortStatsLastTickUnix.Set(float64(time.Now().Unix())) +} + +// normalizeName lower-cases and strips spaces / punctuation so +// "Manifold Markets" and "manifold-markets" both index to the same key. +func normalizeName(s string) string { + s = strings.ToLower(s) + out := make([]byte, 0, len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') { + out = append(out, c) + } + } + return string(out) +} + +// getJSONDefillama is a dedicated client variant so the Polymarket / Kalshi +// / DefiLlama fetchers can each set their own User-Agent header and keep +// the network paths isolated for debug. +func getJSONDefillama(client *http.Client, urlStr string) ([]byte, error) { + req, _ := http.NewRequest("GET", urlStr, nil) + req.Header.Set("User-Agent", defillamaUA) + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("request_error: %w", err) + } + defer resp.Body.Close() + body := make([]byte, 0, 4096) + buf := make([]byte, 4096) + for { + n, err := resp.Body.Read(buf) + if n > 0 { + body = append(body, buf[:n]...) + } + if err != nil { + break + } + } + if resp.StatusCode != 200 { + return nil, fmt.Errorf("status_%d: %s", resp.StatusCode, truncate(string(body), 200)) + } + return body, nil +} diff --git a/harnesses/pm-cohort-stats/cmd/script/kalshi.go b/harnesses/pm-cohort-stats/cmd/script/kalshi.go new file mode 100644 index 00000000..c57f9532 --- /dev/null +++ b/harnesses/pm-cohort-stats/cmd/script/kalshi.go @@ -0,0 +1,547 @@ +package main + +import ( + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "time" +) + +// Kalshi exposes its market catalog on two layers. The public +// /markets endpoint surfaces every active ticker, but the bulk of the +// rows are auto-generated derivative markets with structurally zero +// volume (the headline event-level volume sits on the parent series +// and is not summed onto child rows). The authenticated /markets/trades +// endpoint, by contrast, carries every executed fill across the venue +// in dollar terms. We aggregate from there to recover the real 24h +// trading volume. +// +// Two-track strategy: +// +// tradesScale24h(): pulls recent /markets/trades pages until at least +// ~1 hour of fills is covered, sums count_fp * +// taker_outcome_side price_dollars per trade, scales +// to 24h. Result feeds pm_venue_volume_24h_usd and +// pm_venue_volume_30d_usd (we cap the 30d gauge at +// 24h x 30, an upper-bound estimate, until a proper +// rolling backfill is wired). Requires a Kalshi API +// key pair signed via RSASSA-PSS-SHA256. +// +// marketsCatalog(): paginates /markets?status=open for active market +// count and an OI floor (sum of open_interest_fp * +// last_price_dollars across the visible markets). +// No auth needed. Stays useful even when KALSHI_KEY_ID +// is empty, just doesn't fill the volume gauges. +// +// Auth contract: each request signs `{timestamp_ms}{method}{path}` with +// the supplied RSA private key using RSASSA-PSS-SHA256 (saltLength = +// SHA256.size). Three headers go on every authenticated call: +// KALSHI-ACCESS-KEY the public key ID (UUID-like) +// KALSHI-ACCESS-TIMESTAMP unix milliseconds, decimal string +// KALSHI-ACCESS-SIGNATURE base64(sig) +// +// Kalshi documents this at docs.kalshi.com (Authentication section). We +// verified the contract live against /trade-api/v2/exchange/status and +// /trade-api/v2/markets/trades before shipping. + +const ( + kalshiBase = "https://api.elections.kalshi.com/trade-api/v2" + kalshiPageSize = 1000 + kalshiMaxPages = 20 + kalshiUA = "OCB-pm-cohort-stats/1.0" + + // Catalog walk via /events?with_nested_markets=true. 25 pages × 200 + // events ≈ 47 k nested markets / ~14 s end to end, well below the + // /markets fan-out (20 k rows in 20 pages, similar wall time) but + // hits the populated rows instead of the empty multi-game parlay + // catalogue. Hard cap protects against runaway pagination if Kalshi + // ever stops returning a terminating cursor. + kalshiEventsPageSize = 200 + kalshiEventsMaxPages = 25 + + // Trade aggregation window. We pull pages of trades until we have + // covered at least this much wall-clock time, then scale to 24h. + // 5 min gives a robust estimate without burning the rate budget at + // typical Kalshi flow (~4000 trades/min that's ~20 pages of 1000), + // keeping a single tick under ~40 s. The scaling 24h/5m = 288x is + // honest under stable conditions; for headline events (Super Bowl + // Sunday) the burst still surfaces because pagination shortens. + kalshiTradeSpanTarget = 5 * time.Minute + // Hard upper bound on pages of trades per tick. Each page is 1000 + // trades; at typical Kalshi flow 5 min covers ~20 pages. Cap at + // 60 so a single tick stays bounded under ~2 min worst case (slow + // upstream) and never burns the loop. + kalshiTradesMaxPages = 60 + + // Settled walk window. We look back 180 days of settled markets to + // count those that crossed $1m all-time traded volume. Kalshi binary + // contracts notionally settle at $1, so the raw volume_fp on each + // market is a USD floor and can be compared directly to the 1m + // threshold without price scaling. + kalshiSettledLookback = 180 * 24 * time.Hour + // Heavier pagination cap reserved for the settled-walk only: the + // catalog walk stays gated at kalshiMaxPages. 100 pages * 1000 rows + // = up to 100k markets, plenty of headroom for the ~30-80 pages a + // 180-day settled window currently spans. + kalshiSettledMaxPages = 100 + // USD threshold for the markets_above_1m gauge. Kalshi binary + // contracts pay $1 per share at resolution, so volume_fp >= this + // value implies all-time notional >= $1m. + kalshiMillionDollarThreshold = 1_000_000.0 +) + +var httpClientKalshi = &http.Client{Timeout: 30 * time.Second} + +// kalshiSigner caches the parsed RSA private key so we don't re-parse on +// every signed request. Set once at startup from the env var. +type kalshiSigner struct { + keyID string + privateKey *rsa.PrivateKey +} + +var kalshiAuth *kalshiSigner + +// initKalshiAuth parses the PEM private key once at startup. If parsing +// fails we keep kalshiAuth nil and the trades-aggregation path silently +// skips; the marketsCatalog path still runs. +func initKalshiAuth(keyID, pemBody string) { + if keyID == "" || pemBody == "" { + fmt.Println("[kalshi] auth disabled (KALSHI_KEY_ID or KALSHI_PRIVATE_KEY missing)") + return + } + block, _ := pem.Decode([]byte(pemBody)) + if block == nil { + fmt.Println("[kalshi] auth disabled: PEM decode returned no block") + return + } + pk, err := parseRSAKey(block.Bytes) + if err != nil { + fmt.Printf("[kalshi] auth disabled: parse error: %v\n", err) + return + } + kalshiAuth = &kalshiSigner{keyID: keyID, privateKey: pk} + fmt.Println("[kalshi] auth enabled (RSASSA-PSS-SHA256)") +} + +// parseRSAKey tries both PKCS1 (-----BEGIN RSA PRIVATE KEY-----) and +// PKCS8 (-----BEGIN PRIVATE KEY-----) shapes so we accept whichever +// format the dashboard emitted. +func parseRSAKey(der []byte) (*rsa.PrivateKey, error) { + if k, err := x509.ParsePKCS1PrivateKey(der); err == nil { + return k, nil + } + k2, err := x509.ParsePKCS8PrivateKey(der) + if err != nil { + return nil, err + } + pk, ok := k2.(*rsa.PrivateKey) + if !ok { + return nil, errors.New("not an RSA key") + } + return pk, nil +} + +func fetchAllKalshi() { + v := VenueBySlug("kalshi") + if v == nil { + return + } + go fetchKalshiVenue(*v) +} + +func fetchKalshiVenue(v Venue) { + start := time.Now() + defer func() { + pmCohortStatsFetchLatencyMs.WithLabelValues(v.Slug, "kalshi").Set(float64(time.Since(start).Milliseconds())) + }() + + // Track A: catalog. No auth needed, gives us active count, OI, and + // real-measured vol24h (sum of volume_24h_fp × last_price over every + // nested market the /events walk surfaces). + active, oi, vol24hCatalog, topVol24Public := kalshiMarketsCatalog(v) + + // Track B: aggregate /markets/trades for the per-ticker top-market + // volume only. We used to also publish the trades-scaled total as + // vol24h, but extrapolating a 5-min trades window × 288 to 24h + // inflated the figure 20-50× during sport bursts (vol30d projection + // hit $10.5B vs Kalshi's true ~$0.5-1.5B/month). The catalog sum is + // the actual reported 24h volume per market, no extrapolation. + var topMarket24Trades float64 + if kalshiAuth != nil { + _, topMarket24Trades = kalshiTradesScale24h(v) + } + + // Track C: walk settled markets to count those above $1m all-time. Heavy + // (up to ~80 paginated requests) but the gauge is otherwise null since + // the open-catalog walk only sees active markets. Auth not required. + above1m := kalshiSettledMarketsAbove1m(v) + + vol24h := vol24hCatalog + // 30d gauge: still a projection (vol24h × 30) — Kalshi exposes + // volume_24h_fp and cumulative volume_fp per market, but no native + // 30-day field. The pm-stats reader is expected to surface this as + // an estimate (~$X) and not as a measured 30-day sum. A real 30d + // figure would require a daily snapshot job that stores the + // cumulative volume_fp per event and diffs across 30 days. + vol30d := vol24h * 30 + + pmVenueActiveMarkets.WithLabelValues(v.Slug).Set(active) + pmVenueOpenInterestUsd.WithLabelValues(v.Slug).Set(oi) + // Prefer the trades-grouped top market when authentication is wired and + // returns a useful sample: the public /markets feed only exposes + // auto-generated derivative tickers whose volume24h is structurally + // near zero, so the trades-grouped value is the only one that reflects + // the headline-event top market. Public value is kept as a fallback. + switch { + case topMarket24Trades > 0: + pmVenueTopMarketVolume24hUsd.WithLabelValues(v.Slug).Set(topMarket24Trades) + case topVol24Public > 0: + pmVenueTopMarketVolume24hUsd.WithLabelValues(v.Slug).Set(topVol24Public) + } + if vol24h > 0 { + pmVenueVolume24hUsd.WithLabelValues(v.Slug).Set(vol24h) + pmVenueVolume30dUsd.WithLabelValues(v.Slug).Set(vol30d) + } + if above1m > 0 { + pmVenueMarketsAbove1m.WithLabelValues(v.Slug).Set(float64(above1m)) + } + + pmCohortStatsLastRefresh.WithLabelValues(v.Slug, "kalshi").Set(float64(time.Now().Unix())) + pmCohortStatsLastTickUnix.Set(float64(time.Now().Unix())) + + fmt.Printf("[kalshi][%s] active=%.0f oi=%.0f top24h_public=%.0f top24h_trades=%.0f vol24h_trades=%.0f vol30d_proj=%.0f above1m=%d\n", + v.Slug, active, oi, topVol24Public, topMarket24Trades, vol24h, vol30d, above1m) +} + +// kalshiMarketsCatalog paginates the public /events endpoint with +// nested markets and returns (active_count, oi_dollars, +// vol24h_dollars, top_market_volume_24h_public). The OI and vol24h +// figures are sums of `open_interest_fp × last_price_dollars` and +// `volume_24h_fp × last_price_dollars` across every nested market. +// +// Why /events?with_nested_markets=true instead of /markets?status=open: +// the flat /markets surface is 95%+ noise (auto-generated multi-game +// sports parlay tickers KXMVESPORTSMULTIGAMEEXTENDED-* with zero OI +// and zero volume). Walking 20 pages × 1000 rows surfaced ~600 USD of +// OI on real probes despite Kalshi's actual OI being north of $80M; +// the /events surface groups markets by parent event and emits the +// populated rows first. Spot-check (2026-06-22): 25 pages × 200 events +// = 5 000 events / 47 864 nested markets / total OI ≈ $87.9M, top +// markets: House 2026 control $3.2M, FIFA World Cup 2026 winner +// $3-12M per outcome. +func kalshiMarketsCatalog(v Venue) (active, oi, vol24h, topVol24 float64) { + type market struct { + Status string `json:"status"` + Volume24hFP flexFloat `json:"volume_24h_fp"` + OpenInterestFP flexFloat `json:"open_interest_fp"` + LastPriceDollars flexFloat `json:"last_price_dollars"` + } + type event struct { + Markets []market `json:"markets"` + } + type resp struct { + Events []event `json:"events"` + Cursor string `json:"cursor"` + } + + var cursor string + for page := 0; page < kalshiEventsMaxPages; page++ { + q := url.Values{} + q.Set("status", "open") + q.Set("with_nested_markets", "true") + q.Set("limit", fmt.Sprintf("%d", kalshiEventsPageSize)) + if cursor != "" { + q.Set("cursor", cursor) + } + body, err := kalshiGet(httpClientKalshi, fmt.Sprintf("/events?%s", q.Encode()), false) + if err != nil { + pmCohortStatsFetchErrors.WithLabelValues(v.Slug, "kalshi-catalog", classifyError(err.Error())).Inc() + fmt.Printf("[kalshi-catalog][%s] page=%d error: %v\n", v.Slug, page, err) + break + } + var r resp + if err := json.Unmarshal(body, &r); err != nil { + pmCohortStatsFetchErrors.WithLabelValues(v.Slug, "kalshi-catalog", "parse").Inc() + break + } + if len(r.Events) == 0 { + break + } + for _, ev := range r.Events { + for _, m := range ev.Markets { + price := float64(m.LastPriceDollars) + if price < 0 { + price = 0 + } + if price > 1 { + price = 1 + } + active++ + oi += float64(m.OpenInterestFP) * price + vol24h += float64(m.Volume24hFP) * price + if v24 := float64(m.Volume24hFP); v24 > topVol24 { + topVol24 = v24 + } + } + } + if r.Cursor == "" || r.Cursor == cursor { + break + } + cursor = r.Cursor + } + return active, oi, vol24h, topVol24 +} + +// kalshiTradesScale24h aggregates recent /markets/trades pages until we +// have covered at least kalshiTradeSpanTarget of wall-clock, then scales +// the observed notional to 24h. While walking the trade pages we also +// bucket per-ticker notional so the highest single market's scaled 24h +// volume can be published to pm_venue_top_market_volume_24h_usd. Returns +// (scaledTotal24h, scaledTopMarket24h); both are 0 if we couldn't auth +// or couldn't span a usable window. +func kalshiTradesScale24h(v Venue) (float64, float64) { + type trade struct { + Ticker string `json:"ticker"` + CountFP flexFloat `json:"count_fp"` + YesPriceDollars flexFloat `json:"yes_price_dollars"` + NoPriceDollars flexFloat `json:"no_price_dollars"` + TakerSide string `json:"taker_outcome_side"` + CreatedTime string `json:"created_time"` + } + type resp struct { + Trades []trade `json:"trades"` + Cursor string `json:"cursor"` + } + + var ( + cursor string + total float64 + oldestTs time.Time + newestTs time.Time + tradesProcessed int + perTicker = make(map[string]float64) + ) + for page := 0; page < kalshiTradesMaxPages; page++ { + path := fmt.Sprintf("/markets/trades?limit=%d", kalshiPageSize) + if cursor != "" { + path += "&cursor=" + url.QueryEscape(cursor) + } + body, err := kalshiGet(httpClientKalshi, path, true) + if err != nil { + pmCohortStatsFetchErrors.WithLabelValues(v.Slug, "kalshi-trades", classifyError(err.Error())).Inc() + fmt.Printf("[kalshi-trades][%s] page=%d error: %v\n", v.Slug, page, err) + break + } + var r resp + if err := json.Unmarshal(body, &r); err != nil { + pmCohortStatsFetchErrors.WithLabelValues(v.Slug, "kalshi-trades", "parse").Inc() + break + } + if len(r.Trades) == 0 { + break + } + for _, t := range r.Trades { + ts, err := time.Parse(time.RFC3339Nano, t.CreatedTime) + if err != nil { + continue + } + if oldestTs.IsZero() || ts.Before(oldestTs) { + oldestTs = ts + } + if newestTs.IsZero() || ts.After(newestTs) { + newestTs = ts + } + price := float64(t.YesPriceDollars) + if strings.EqualFold(t.TakerSide, "no") { + price = float64(t.NoPriceDollars) + } + if price <= 0 || price > 1 { + continue + } + notional := float64(t.CountFP) * price + total += notional + if t.Ticker != "" { + perTicker[t.Ticker] += notional + } + tradesProcessed++ + } + if !newestTs.IsZero() && !oldestTs.IsZero() { + if newestTs.Sub(oldestTs) >= kalshiTradeSpanTarget { + break + } + } + if r.Cursor == "" || r.Cursor == cursor { + break + } + cursor = r.Cursor + } + if newestTs.IsZero() || oldestTs.IsZero() || newestTs.Equal(oldestTs) { + return 0, 0 + } + span := newestTs.Sub(oldestTs) + if span <= 0 { + return 0, 0 + } + scale := (24 * time.Hour).Seconds() / span.Seconds() + scaled := total * scale + var ( + topTicker string + topRaw float64 + ) + for ticker, sum := range perTicker { + if sum > topRaw { + topRaw = sum + topTicker = ticker + } + } + scaledTop := topRaw * scale + fmt.Printf("[kalshi-trades][%s] span=%.2fmin trades=%d tickers=%d notional=$%.0f scaled24h=$%.0f topTicker=%s topRaw=$%.0f scaledTop24h=$%.0f\n", + "kalshi", span.Minutes(), tradesProcessed, len(perTicker), total, scaled, topTicker, topRaw, scaledTop) + return scaled, scaledTop +} + +// kalshiSettledMarketsAbove1m walks /markets?status=settled over the last +// kalshiSettledLookback window and counts how many settled markets crossed +// $1m notional all-time. Kalshi binary contracts pay $1 per share at +// resolution, so volume_fp is a USD floor we can compare to the threshold +// without any price scaling. +// +// Heavy operation by design: up to kalshiSettledMaxPages * kalshiPageSize +// rows per call (~80k markets). We time the whole walk so the OCB latency +// dashboard surfaces regressions, and we bucket failures under source +// "kalshi-settled" so they do not pollute the catalog and trades counters. +func kalshiSettledMarketsAbove1m(v Venue) int { + type market struct { + VolumeFP flexFloat `json:"volume"` + // Kalshi exposes the cumulative volume under multiple keys depending + // on the response shape. We prefer `volume` (raw dollar count) and + // fall back to `volume_fp` for robustness across schema drift. + VolumeFPAlt flexFloat `json:"volume_fp"` + } + type resp struct { + Markets []market `json:"markets"` + Cursor string `json:"cursor"` + } + + start := time.Now() + defer func() { + pmCohortStatsFetchLatencyMs.WithLabelValues(v.Slug, "kalshi-settled").Set(float64(time.Since(start).Milliseconds())) + }() + + minSettledTs := time.Now().Add(-kalshiSettledLookback).Unix() + var ( + cursor string + count int + seenMarkets int + ) + for page := 0; page < kalshiSettledMaxPages; page++ { + q := url.Values{} + q.Set("status", "settled") + q.Set("min_settled_ts", fmt.Sprintf("%d", minSettledTs)) + q.Set("limit", fmt.Sprintf("%d", kalshiPageSize)) + if cursor != "" { + q.Set("cursor", cursor) + } + body, err := kalshiGet(httpClientKalshi, fmt.Sprintf("/markets?%s", q.Encode()), false) + if err != nil { + pmCohortStatsFetchErrors.WithLabelValues(v.Slug, "kalshi-settled", classifyError(err.Error())).Inc() + fmt.Printf("[kalshi-settled][%s] page=%d error: %v\n", v.Slug, page, err) + break + } + var r resp + if err := json.Unmarshal(body, &r); err != nil { + pmCohortStatsFetchErrors.WithLabelValues(v.Slug, "kalshi-settled", "parse").Inc() + break + } + if len(r.Markets) == 0 { + break + } + for _, m := range r.Markets { + vol := float64(m.VolumeFP) + if vol == 0 { + vol = float64(m.VolumeFPAlt) + } + seenMarkets++ + if vol >= kalshiMillionDollarThreshold { + count++ + } + } + if r.Cursor == "" || r.Cursor == cursor { + break + } + cursor = r.Cursor + } + fmt.Printf("[kalshi-settled][%s] window=180d seen=%d above1m=%d elapsed=%s\n", + v.Slug, seenMarkets, count, time.Since(start).Round(time.Millisecond)) + pmCohortStatsLastRefresh.WithLabelValues(v.Slug, "kalshi-settled").Set(float64(time.Now().Unix())) + return count +} + +// kalshiGet performs the HTTP GET, optionally signing with the RSA key +// when `signed` is true. Unsigned calls just set the UA + Accept header. +func kalshiGet(client *http.Client, path string, signed bool) ([]byte, error) { + fullURL := kalshiBase + path + req, _ := http.NewRequest("GET", fullURL, nil) + req.Header.Set("User-Agent", kalshiUA) + req.Header.Set("Accept", "application/json") + if signed { + if kalshiAuth == nil { + return nil, fmt.Errorf("auth_unconfigured") + } + ts := fmt.Sprintf("%d", time.Now().UnixMilli()) + // Kalshi signs the path AS REQUESTED by the client, including + // the v2 prefix. We sign the trailing path that lives under + // the /trade-api/v2 base, in form `{ts}{method}{path}`. Live + // against /exchange/status returned 200 — contract verified. + msg := ts + "GET" + "/trade-api/v2" + path + sig, err := rsaPssSignSha256(kalshiAuth.privateKey, []byte(msg)) + if err != nil { + return nil, fmt.Errorf("sign_error: %w", err) + } + req.Header.Set("KALSHI-ACCESS-KEY", kalshiAuth.keyID) + req.Header.Set("KALSHI-ACCESS-TIMESTAMP", ts) + req.Header.Set("KALSHI-ACCESS-SIGNATURE", base64.StdEncoding.EncodeToString(sig)) + } + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("request_error: %w", err) + } + defer resp.Body.Close() + body := make([]byte, 0, 4096) + buf := make([]byte, 4096) + for { + n, err := resp.Body.Read(buf) + if n > 0 { + body = append(body, buf[:n]...) + } + if err != nil { + break + } + } + if resp.StatusCode != 200 { + return nil, fmt.Errorf("status_%d: %s", resp.StatusCode, truncate(string(body), 200)) + } + return body, nil +} + +// rsaPssSignSha256 performs RSASSA-PSS with SHA-256 + saltLength = +// SHA-256.Size, matching Kalshi's documented signature contract. +func rsaPssSignSha256(pk *rsa.PrivateKey, msg []byte) ([]byte, error) { + h := crypto.SHA256.New() + h.Write(msg) + digest := h.Sum(nil) + opts := &rsa.PSSOptions{ + SaltLength: rsa.PSSSaltLengthEqualsHash, + Hash: crypto.SHA256, + } + return rsa.SignPSS(rand.Reader, pk, crypto.SHA256, digest, opts) +} diff --git a/harnesses/pm-cohort-stats/cmd/script/limitless.go b/harnesses/pm-cohort-stats/cmd/script/limitless.go new file mode 100644 index 00000000..d5dedb47 --- /dev/null +++ b/harnesses/pm-cohort-stats/cmd/script/limitless.go @@ -0,0 +1,201 @@ +package main + +import ( + "encoding/json" + "fmt" + "math" + "net/http" + "strconv" + "time" +) + +// Limitless exposes a public unauthenticated REST API at api.limitless.exchange. +// It does NOT expose a per-market 24h or 30d volume field. The /markets/active +// payload only carries a CUMULATIVE `volume` (raw USDC base units, divide by +// collateralToken.decimals) and its formatted twin `volumeFormatted` (USD, +// native USDC so 1:1). To recover proper 24h / 30d notional we would need +// one of: +// +// (a) Envio HyperIndex GraphQL reverse-engineering against the on-chain +// conditional-token contracts on Base, summing trade events in a window. +// (b) A persistent 1-minute /feed poller that maintains rolling state +// across ticks and computes deltas (process-local; doesn't survive +// restarts without a sidecar store). +// (c) DefiLlama's 24h derivative metric for the Limitless protocol, when +// it stabilises (today it under-reports vs on-chain). +// +// All three are out of scope for the MVP. This file ships only what the +// public REST cleanly supports: +// +// pm_venue_active_markets{venue=limitless} +// Authoritative. totalMarketsCount from /markets/active page 1. +// +// pm_venue_top_market_volume_24h_usd{venue=limitless} +// LIFETIME PROXY. max(volumeFormatted) across all active USDC markets. +// The metric Help text is updated to disclose the proxy nature. +// +// pm_venue_markets_above_1m{venue=limitless} +// LIFETIME PROXY. count where volumeFormatted >= 1_000_000. +// Same disclosure. +// +// pm_venue_volume_24h_usd and pm_venue_volume_30d_usd are deliberately NOT +// touched: Prometheus carries the previous-tick value forward (or remains +// absent if never set), which is the honest signal until one of (a/b/c) +// lands. pm_venue_open_interest_usd for Limitless STAYS on DefiLlama TVL +// (see defillama.go); we do not write OI from here. +// +// Per CLAUDE.md MVP rule: zero speculative features. If a structural gap +// can't be filled honestly, surface the gap, don't paper it over. + +const ( + limitlessBase = "https://api.limitless.exchange" + limitlessPageSize = 25 // server-side hard cap; limit > 25 is silently clamped. + limitlessUA = "OCB-pm-cohort-stats/1.0" + // Politeness budget. Limitless does not advertise x-ratelimit-* headers + // and there's no documented hard cap; the host is Cloudflare-fronted. + // 200 ms between page fetches is well under any sane CDN ceiling and + // keeps a full walk of ~10 pages under ~2 s. + limitlessInterPageDelay = 200 * time.Millisecond + // Hard safety cap on pages per tick. At pageSize=25, 200 pages covers + // 5000 active markets; the live count is well under 1000 today. + limitlessMaxPages = 200 +) + +var httpClientLimitless = &http.Client{Timeout: 20 * time.Second} + +// limitlessMarket mirrors the subset of /markets/active row fields we read. +// `volume` is a stringified raw USDC base-unit count; `volumeFormatted` is +// a stringified USD figure (native USDC, 1:1). Status FUNDED + null +// winningOutcomeIndex = open. We filter to USDC collateral only. +type limitlessMarket struct { + Volume string `json:"volume"` + VolumeFormatted string `json:"volumeFormatted"` + Status string `json:"status"` + WinningOutcome *int `json:"winningOutcomeIndex"` + CollateralToken struct { + Symbol string `json:"symbol"` + Decimals int `json:"decimals"` + } `json:"collateralToken"` +} + +type limitlessActiveResp struct { + Data []limitlessMarket `json:"data"` + TotalMarketsCount int `json:"totalMarketsCount"` +} + +func fetchAllLimitless() { + v := VenueBySlug("limitless") + if v == nil { + return + } + go fetchLimitlessVenue(*v) +} + +func fetchLimitlessVenue(v Venue) { + start := time.Now() + defer func() { + pmCohortStatsFetchLatencyMs.WithLabelValues(v.Slug, "limitless").Set(float64(time.Since(start).Milliseconds())) + }() + + // Page 1 doubles as the totalMarketsCount probe so we know how many + // pages to walk. + first, err := limitlessFetchPage(1) + if err != nil { + pmCohortStatsFetchErrors.WithLabelValues(v.Slug, "limitless", classifyError(err.Error())).Inc() + fmt.Printf("[limitless][%s] page=1 error: %v\n", v.Slug, err) + return + } + + pmVenueActiveMarkets.WithLabelValues(v.Slug).Set(float64(first.TotalMarketsCount)) + + var ( + maxVol float64 + above1mCount float64 + pagesWalked = 1 + rowsConsidered int + ) + process := func(rows []limitlessMarket) { + for _, m := range rows { + if m.CollateralToken.Symbol != "USDC" { + continue + } + vf, err := strconv.ParseFloat(m.VolumeFormatted, 64) + if err != nil || math.IsNaN(vf) || math.IsInf(vf, 0) { + continue + } + rowsConsidered++ + if vf > maxVol { + maxVol = vf + } + if vf >= 1_000_000 { + above1mCount++ + } + } + } + process(first.Data) + + // totalMarketsCount / 25 rounded up = total pages. We already have page 1. + totalPages := (first.TotalMarketsCount + limitlessPageSize - 1) / limitlessPageSize + if totalPages > limitlessMaxPages { + totalPages = limitlessMaxPages + } + for page := 2; page <= totalPages; page++ { + time.Sleep(limitlessInterPageDelay) + r, err := limitlessFetchPage(page) + if err != nil { + pmCohortStatsFetchErrors.WithLabelValues(v.Slug, "limitless", classifyError(err.Error())).Inc() + fmt.Printf("[limitless][%s] page=%d error: %v\n", v.Slug, page, err) + break + } + if len(r.Data) == 0 { + break + } + process(r.Data) + pagesWalked++ + } + + // Lifetime proxies: publish unconditionally so the gauge reflects + // today's snapshot, even when zero (a brand-new restart shouldn't + // inherit a stale Polymarket-style carry-forward from a prior bug). + pmVenueTopMarketVolume24hUsd.WithLabelValues(v.Slug).Set(maxVol) + pmVenueMarketsAbove1m.WithLabelValues(v.Slug).Set(above1mCount) + + pmCohortStatsLastRefresh.WithLabelValues(v.Slug, "limitless").Set(float64(time.Now().Unix())) + pmCohortStatsLastTickUnix.Set(float64(time.Now().Unix())) + + fmt.Printf("[limitless][%s] active=%d pages=%d/%d usdc_rows=%d top_lifetime=%.0f above1m_lifetime=%.0f\n", + v.Slug, first.TotalMarketsCount, pagesWalked, totalPages, rowsConsidered, maxVol, above1mCount) +} + +// limitlessFetchPage hits /markets/active?page=N&limit=25&sortBy=newest. +// `newest` is the only sortBy that validates today; other values 400. +func limitlessFetchPage(page int) (*limitlessActiveResp, error) { + url := fmt.Sprintf("%s/markets/active?page=%d&limit=%d&sortBy=newest", limitlessBase, page, limitlessPageSize) + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("User-Agent", limitlessUA) + req.Header.Set("Accept", "application/json") + resp, err := httpClientLimitless.Do(req) + if err != nil { + return nil, fmt.Errorf("request_error: %w", err) + } + defer resp.Body.Close() + body := make([]byte, 0, 4096) + buf := make([]byte, 4096) + for { + n, err := resp.Body.Read(buf) + if n > 0 { + body = append(body, buf[:n]...) + } + if err != nil { + break + } + } + if resp.StatusCode != 200 { + return nil, fmt.Errorf("status_%d: %s", resp.StatusCode, truncate(string(body), 200)) + } + var r limitlessActiveResp + if err := json.Unmarshal(body, &r); err != nil { + return nil, fmt.Errorf("parse: %w", err) + } + return &r, nil +} diff --git a/harnesses/pm-cohort-stats/cmd/script/main.go b/harnesses/pm-cohort-stats/cmd/script/main.go new file mode 100644 index 00000000..75bd332e --- /dev/null +++ b/harnesses/pm-cohort-stats/cmd/script/main.go @@ -0,0 +1,189 @@ +// pm-cohort-stats is a small Prom-exporter harness that polls prediction +// market data sources and exposes per-venue cohort gauges the OCB site +// renders on the PM benches strip and the /benches/pm-* product pages. +// +// === Gauges exposed =============================================== +// pm_venue_volume_30d_usd{venue} Notional vol rolling 30d +// pm_venue_volume_24h_usd{venue} Notional vol last 24h +// pm_venue_open_interest_usd{venue} Current OI +// pm_venue_active_markets{venue} Open markets right now +// pm_venue_top_market_volume_24h_usd{venue} Highest single market 24h +// pm_venue_markets_above_1m{venue} Markets crossed $1m all-time +// +// Each gauge is publish-then-leave: if a fetch fails for one venue on +// one source, the previous value carries forward via Prom retention, +// other venues are unaffected, and the error is bucketed in +// pm_cohort_stats_fetch_errors_total{venue, source, error_type}. +// +// HTTP server is fixed at :2112 per the OCB harness convention (see +// CLAUDE.md memory note: every OCB harness on Railway hard-codes :2112 +// so the shared Prom-gateway scrape target matches). +package main + +import ( + "fmt" + "os" + "os/signal" + "sync" + "syscall" + "time" +) + +func main() { + fmt.Println("=== pm-cohort-stats harness ===") + fmt.Println("Per-venue PM cohort stats from Polymarket gamma, Kalshi REST, Limitless REST, Myriad v2, Manifold, and DefiLlama.") + fmt.Println("Exposes /metrics on :2112.") + + cfg := loadConfig() + + // Parse the Kalshi RSA private key once at startup. If absent, the + // trades-aggregation path silently no-ops; the public marketsCatalog + // path still populates active count + OI. + initKalshiAuth(cfg.KalshiKeyID, cfg.KalshiPrivateKey) + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + + var wg sync.WaitGroup + stop := make(chan struct{}) + + 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) + } + }() + + wg.Add(1) + go func() { + defer wg.Done() + runPolymarketLoop(cfg, stop) + }() + + wg.Add(1) + go func() { + defer wg.Done() + runKalshiLoop(cfg, stop) + }() + + wg.Add(1) + go func() { + defer wg.Done() + runDefillamaLoop(cfg, stop) + }() + + wg.Add(1) + go func() { + defer wg.Done() + runLimitlessLoop(cfg, stop) + }() + + wg.Add(1) + go func() { + defer wg.Done() + runMyriadLoop(cfg, stop) + }() + + wg.Add(1) + go func() { + defer wg.Done() + runManifoldLoop(cfg, stop) + }() + + <-sigChan + fmt.Println("\nShutting down...") + close(stop) + wg.Wait() +} + +func runPolymarketLoop(cfg *Config, stop <-chan struct{}) { + tick := time.NewTicker(cfg.PolymarketRefreshInterval) + defer tick.Stop() + + fetchAllPolymarket() + for { + select { + case <-stop: + return + case <-tick.C: + fetchAllPolymarket() + } + } +} + +func runKalshiLoop(cfg *Config, stop <-chan struct{}) { + tick := time.NewTicker(cfg.KalshiRefreshInterval) + defer tick.Stop() + + fetchAllKalshi() + for { + select { + case <-stop: + return + case <-tick.C: + fetchAllKalshi() + } + } +} + +func runDefillamaLoop(cfg *Config, stop <-chan struct{}) { + tick := time.NewTicker(cfg.DefillamaRefreshInterval) + defer tick.Stop() + + fetchAllDefillama() + for { + select { + case <-stop: + return + case <-tick.C: + fetchAllDefillama() + } + } +} + +func runLimitlessLoop(cfg *Config, stop <-chan struct{}) { + tick := time.NewTicker(cfg.LimitlessRefreshInterval) + defer tick.Stop() + + fetchAllLimitless() + for { + select { + case <-stop: + return + case <-tick.C: + fetchAllLimitless() + } + } +} + +func runMyriadLoop(cfg *Config, stop <-chan struct{}) { + tick := time.NewTicker(cfg.MyriadRefreshInterval) + defer tick.Stop() + + fetchAllMyriad() + for { + select { + case <-stop: + return + case <-tick.C: + fetchAllMyriad() + } + } +} + +func runManifoldLoop(cfg *Config, stop <-chan struct{}) { + tick := time.NewTicker(cfg.ManifoldRefreshInterval) + defer tick.Stop() + + fetchAllManifold(cfg.ManifoldManaUsdRate) + for { + select { + case <-stop: + return + case <-tick.C: + fetchAllManifold(cfg.ManifoldManaUsdRate) + } + } +} diff --git a/harnesses/pm-cohort-stats/cmd/script/manifold.go b/harnesses/pm-cohort-stats/cmd/script/manifold.go new file mode 100644 index 00000000..9b7e9771 --- /dev/null +++ b/harnesses/pm-cohort-stats/cmd/script/manifold.go @@ -0,0 +1,247 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "time" +) + +// Manifold is a public, no-auth prediction market with its own REST +// surface at api.manifold.markets/v0. Rate limit is 500 req/min per IP +// (confirmed live). We walk /search-markets paginated through the open +// universe and add a single page of resolved markets to catch anything +// that resolved in the last 24h (and to count the historical $1M+ set). +// +// IMPORTANT STRUCTURAL NOTE: Manifold is PLAY-MONEY. The unit is "mana" +// and there is NO official mana->USD exchange rate. References available: +// +// - Legacy charity donation rate: 1000 mana = $1 USD (still surfaces on +// the donation page; the only published number that ties mana to USD). +// - Sweepstakes CASH token: $1 = 1 CASH directly, but the CASH surface +// on /search-markets is currently empty so it doesn't contribute. +// +// DECISION: we publish DUAL gauges. The mana-denominated family +// (pm_venue_*_mana) is the raw, source-of-truth number. The existing +// USD family (pm_venue_*_usd) is scaled by MANIFOLD_MANA_USD_RATE +// (default 0.001, the charity rate). Both sides carry explicit Help +// text on the metrics.go side disclosing that the conversion is a +// disclosed convention, not a market price. +// +// CASH-denominated rows, if any come back, are multiplied by 1.0 (since +// 1 CASH = $1 USD directly) so they aggregate cleanly into the USD-side +// without polluting the mana-side numerator. + +const ( + manifoldBase = "https://api.manifold.markets/v0" + manifoldUA = "OCB-pm-cohort-stats/1.0" + manifoldPageSize = 1000 + // Safety cap: 5 pages * 1000 = 5k markets. Live open universe is + // ~1.1k so this is 4x headroom; if the open set blows up past 5k + // the loop terminates cleanly and the values are still useful. + manifoldMaxOpenPages = 5 + // 1B mana threshold = $1M USD at the default 0.001 charity rate. + manifoldOneBillionMana = 1_000_000_000.0 +) + +var httpClientManifold = &http.Client{Timeout: 30 * time.Second} + +// manifoldMarket mirrors only the fields we consume from +// /v0/search-markets. Numbers are unboxed floats on this endpoint, so +// no flexFloat is needed. +type manifoldMarket struct { + Volume float64 `json:"volume"` + Volume24Hours float64 `json:"volume24Hours"` + TotalLiquidity float64 `json:"totalLiquidity"` + Token string `json:"token"` + IsResolved bool `json:"isResolved"` + CloseTime int64 `json:"closeTime"` + ResolutionTime int64 `json:"resolutionTime"` +} + +func fetchAllManifold(rate float64) { + v := VenueBySlug("manifold") + if v == nil { + return + } + go fetchManifoldVenue(*v, rate) +} + +func fetchManifoldVenue(v Venue, rate float64) { + start := time.Now() + defer func() { + pmCohortStatsFetchLatencyMs.WithLabelValues(v.Slug, "manifold").Set(float64(time.Since(start).Milliseconds())) + }() + + var ( + vol24Sum float64 // mana (CASH rows convert to mana-equivalent via 1/rate so the USD-side + // stays coherent, but for clarity we keep mana-side accumulators pure mana and + // add CASH contributions separately to the USD-side accumulators below). + oiSum float64 // mana, open markets only + activeCount float64 + topVol24 float64 // mana, max across all sweeps + above1bMana float64 // count where lifetime mana >= 1e9 (i.e. $1M at charity rate) + + // USD-side accumulators. For MANA rows: mana * rate. For CASH rows: value * 1.0. + vol24SumUsd float64 + oiSumUsd float64 + topVol24Usd float64 + above1mUsd float64 // count where lifetime USD-equivalent >= $1M + + pagesOK int + ) + + consider := func(rows []manifoldMarket, includeInOI bool) { + for _, m := range rows { + isCash := m.Token == "CASH" + // Per-row USD value scaler. For MANA rows: mana * rate. + // For CASH rows: 1.0 (CASH = $1 directly). + usdScale := rate + if isCash { + usdScale = 1.0 + } + + // 24h volume contribution. Both mana-side and USD-side capture + // MANA rows; CASH rows ONLY contribute to USD-side (mana-side + // is by definition pure mana). + if !isCash { + vol24Sum += m.Volume24Hours + if m.Volume24Hours > topVol24 { + topVol24 = m.Volume24Hours + } + if m.Volume >= manifoldOneBillionMana { + above1bMana++ + } + } + vol24Usd := m.Volume24Hours * usdScale + vol24SumUsd += vol24Usd + if vol24Usd > topVol24Usd { + topVol24Usd = vol24Usd + } + if m.Volume*usdScale >= 1_000_000 { + above1mUsd++ + } + + // Open-interest proxy: AMM seed (totalLiquidity). Open markets + // only, so the resolved sweep doesn't pollute the OI gauge. + if includeInOI { + if !isCash { + oiSum += m.TotalLiquidity + } + oiSumUsd += m.TotalLiquidity * usdScale + activeCount++ + } + } + } + + // Pass 1: open markets, sorted by 24h volume, paginate to the end. + for page := 0; page < manifoldMaxOpenPages; page++ { + offset := page * manifoldPageSize + url := fmt.Sprintf("%s/search-markets?term=&filter=open&sort=24-hour-vol&limit=%d&offset=%d", + manifoldBase, manifoldPageSize, offset) + body, err := getJSONManifold(httpClientManifold, url) + if err != nil { + pmCohortStatsFetchErrors.WithLabelValues(v.Slug, "manifold", classifyError(err.Error())).Inc() + fmt.Printf("[manifold][%s] open page=%d error: %v\n", v.Slug, page, err) + break + } + var rows []manifoldMarket + if err := json.Unmarshal(body, &rows); err != nil { + pmCohortStatsFetchErrors.WithLabelValues(v.Slug, "manifold", "parse").Inc() + fmt.Printf("[manifold][%s] open page=%d parse error: %v\n", v.Slug, page, err) + break + } + if len(rows) == 0 { + break + } + consider(rows, true) + pagesOK++ + if len(rows) < manifoldPageSize { + break + } + } + + // Pass 2: single page of recently resolved markets. Catches markets + // that resolved in the last 24h (they would otherwise drop out of + // the open sweep entirely) AND surfaces historical big winners so + // the >= $1M counter doesn't undercount the universe. + { + url := fmt.Sprintf("%s/search-markets?term=&filter=resolved&sort=newest&limit=%d&offset=0", + manifoldBase, manifoldPageSize) + body, err := getJSONManifold(httpClientManifold, url) + if err != nil { + pmCohortStatsFetchErrors.WithLabelValues(v.Slug, "manifold", classifyError(err.Error())).Inc() + fmt.Printf("[manifold][%s] resolved page error: %v\n", v.Slug, err) + } else { + var rows []manifoldMarket + if err := json.Unmarshal(body, &rows); err != nil { + pmCohortStatsFetchErrors.WithLabelValues(v.Slug, "manifold", "parse").Inc() + fmt.Printf("[manifold][%s] resolved parse error: %v\n", v.Slug, err) + } else { + consider(rows, false) + pagesOK++ + } + } + } + + if pagesOK == 0 { + pmCohortStatsLastTickUnix.Set(float64(time.Now().Unix())) + return + } + + // 30d projection: 24h * 30 is an UPPER BOUND under stable flow, not a + // rolling sum. Mirrors the Kalshi convention and is documented in the + // gauge Help text. + vol30Sum := vol24Sum * 30 + vol30SumUsd := vol24SumUsd * 30 + + // Publish mana-side gauges. + pmVenueVolume24hMana.WithLabelValues(v.Slug).Set(vol24Sum) + pmVenueVolume30dMana.WithLabelValues(v.Slug).Set(vol30Sum) + pmVenueOpenInterestMana.WithLabelValues(v.Slug).Set(oiSum) + pmVenueTopMarketVolume24hMana.WithLabelValues(v.Slug).Set(topVol24) + pmVenueMarketsAbove1bMana.WithLabelValues(v.Slug).Set(above1bMana) + + // Publish USD-side gauges (charity-rate scaled, with CASH rows at $1). + pmVenueVolume24hUsd.WithLabelValues(v.Slug).Set(vol24SumUsd) + pmVenueVolume30dUsd.WithLabelValues(v.Slug).Set(vol30SumUsd) + pmVenueOpenInterestUsd.WithLabelValues(v.Slug).Set(oiSumUsd) + pmVenueActiveMarkets.WithLabelValues(v.Slug).Set(activeCount) + pmVenueTopMarketVolume24hUsd.WithLabelValues(v.Slug).Set(topVol24Usd) + pmVenueMarketsAbove1m.WithLabelValues(v.Slug).Set(above1mUsd) + + pmCohortStatsLastRefresh.WithLabelValues(v.Slug, "manifold").Set(float64(time.Now().Unix())) + pmCohortStatsLastTickUnix.Set(float64(time.Now().Unix())) + + fmt.Printf("[manifold][%s] pages=%d active=%.0f vol24h_mana=%.0f vol24h_usd=%.2f oi_mana=%.0f oi_usd=%.2f top24h_mana=%.0f above1b_mana=%.0f above1m_usd=%.0f rate=%v\n", + v.Slug, pagesOK, activeCount, vol24Sum, vol24SumUsd, oiSum, oiSumUsd, topVol24, above1bMana, above1mUsd, rate) +} + +// getJSONManifold is a dedicated client wrapper so we can carry our own +// UA and isolate the network path for debug, matching the per-source +// convention in polymarket.go / defillama.go / kalshi.go. +func getJSONManifold(client *http.Client, urlStr string) ([]byte, error) { + req, _ := http.NewRequest("GET", urlStr, nil) + req.Header.Set("User-Agent", manifoldUA) + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("request_error: %w", err) + } + defer resp.Body.Close() + body := make([]byte, 0, 4096) + buf := make([]byte, 4096) + for { + n, err := resp.Body.Read(buf) + if n > 0 { + body = append(body, buf[:n]...) + } + if err != nil { + break + } + } + if resp.StatusCode != 200 { + return nil, fmt.Errorf("status_%d: %s", resp.StatusCode, truncate(string(body), 200)) + } + return body, nil +} diff --git a/harnesses/pm-cohort-stats/cmd/script/metrics.go b/harnesses/pm-cohort-stats/cmd/script/metrics.go new file mode 100644 index 00000000..5eedd6ab --- /dev/null +++ b/harnesses/pm-cohort-stats/cmd/script/metrics.go @@ -0,0 +1,182 @@ +package main + +import ( + "net/http" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +// All gauges are keyed by `venue=`. The site reads them with +// the exact same selector via the PM cohort fetcher. Naming convention +// is `pm_venue_` so a reader can tell at a glance that the value +// comes from the cohort-stats harness; the producing source is recorded +// alongside in the observability counters, not in the gauge name. +var ( + // Cohort gauges ===================================================== + pmVenueVolume30dUsd = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "pm_venue_volume_30d_usd", + Help: "Notional traded volume in USD, rolling 30 days, per venue. Source: Polymarket gamma /markets volume1mo, Kalshi /markets volume aggregated (proxy), Myriad /markets sum(volume) over rows with publishedAt > now-30d (APPROXIMATION: counts lifetime volume of markets first published in the last 30d, not a true rolling sum), DefiLlama /protocols 30d fallback. For Manifold (play-money), this is the mana-denominated figure scaled by MANIFOLD_MANA_USD_RATE (default 0.001 = legacy charity donation rate, NOT a market exchange rate); see pm_venue_volume_30d_mana for the raw value.", + }, + []string{"venue"}, + ) + pmVenueVolume24hUsd = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "pm_venue_volume_24h_usd", + Help: "Notional traded volume in USD over the last 24h, per venue. Source: Polymarket gamma /markets volume24hr, Kalshi /markets recent activity, Myriad /markets sum(volumeNotional24h) across open + just-resolved USD-stable markets, DefiLlama /protocols 24h fallback. For Manifold (play-money), this is the mana-denominated figure scaled by MANIFOLD_MANA_USD_RATE (default 0.001 = legacy charity donation rate, NOT a market exchange rate); see pm_venue_volume_24h_mana for the raw value.", + }, + []string{"venue"}, + ) + pmVenueOpenInterestUsd = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "pm_venue_open_interest_usd", + Help: "Current open interest in USD, per venue. Source: Polymarket gamma /markets openInterest sum, Kalshi /markets open_interest sum scaled by last_price, Myriad /markets sum(liquidity * liquidityPrice) across USD-stable open markets (replaces the prior DefiLlama TVL proxy). For Manifold (play-money), this is the sum of totalLiquidity (AMM seed, mana) scaled by MANIFOLD_MANA_USD_RATE (default 0.001 = legacy charity donation rate, NOT a market exchange rate); see pm_venue_open_interest_mana for the raw value.", + }, + []string{"venue"}, + ) + pmVenueActiveMarkets = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "pm_venue_active_markets", + Help: "Number of markets open right now, per venue. Source: count of /markets rows with status=open / active=true and not closed.", + }, + []string{"venue"}, + ) + pmVenueTopMarketVolume24hUsd = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "pm_venue_top_market_volume_24h_usd", + Help: "Highest single-market 24h volume in USD, per venue. Source: max(volume24hr) across active markets. For Limitless this is a lifetime proxy - the public REST does not expose a 24h aggregate field. For Manifold (play-money), this is the mana-denominated figure scaled by MANIFOLD_MANA_USD_RATE (default 0.001 = legacy charity donation rate, NOT a market exchange rate); see pm_venue_top_market_volume_24h_mana for the raw value.", + }, + []string{"venue"}, + ) + pmVenueMarketsAbove1m = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "pm_venue_markets_above_1m", + Help: "Number of markets that have crossed $1m all-time traded volume, per venue. Source: count of /markets rows with volume >= 1_000_000. For Limitless this is a lifetime proxy - the public REST does not expose a 24h aggregate field. For Manifold (play-money), the threshold is 1_000_000_000 mana ($1M at the default 0.001 charity rate); see pm_venue_markets_above_1b_mana for the raw mana-side counter.", + }, + []string{"venue"}, + ) + + // Manifold mana-denominated gauges =================================== + // Manifold is play-money: the unit is mana, with no official mana->USD + // market exchange rate. We expose a parallel mana-denominated gauge + // family alongside the existing pm_venue_*_usd gauges so dashboards + // can render the raw value and the conversion side by side. + pmVenueVolume30dMana = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "pm_venue_volume_30d_mana", + Help: "Manifold play-money raw value (mana), 30d projection from 24h * 30; pm_venue_volume_30d_usd is the same scaled by MANIFOLD_MANA_USD_RATE (default 0.001 from the legacy charity donation rate, NOT a market exchange rate).", + }, + []string{"venue"}, + ) + pmVenueVolume24hMana = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "pm_venue_volume_24h_mana", + Help: "Manifold play-money raw value (mana); pm_venue_volume_24h_usd is the same scaled by MANIFOLD_MANA_USD_RATE (default 0.001 from the legacy charity donation rate, NOT a market exchange rate).", + }, + []string{"venue"}, + ) + pmVenueOpenInterestMana = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "pm_venue_open_interest_mana", + Help: "Manifold play-money raw value (mana), sum of totalLiquidity across open markets used as the OI proxy; pm_venue_open_interest_usd is the same scaled by MANIFOLD_MANA_USD_RATE (default 0.001 from the legacy charity donation rate, NOT a market exchange rate).", + }, + []string{"venue"}, + ) + pmVenueTopMarketVolume24hMana = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "pm_venue_top_market_volume_24h_mana", + Help: "Manifold play-money raw value (mana), highest single-market 24h volume; pm_venue_top_market_volume_24h_usd is the same scaled by MANIFOLD_MANA_USD_RATE (default 0.001 from the legacy charity donation rate, NOT a market exchange rate).", + }, + []string{"venue"}, + ) + pmVenueMarketsAbove1bMana = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "pm_venue_markets_above_1b_mana", + Help: "Manifold play-money mana-side counter: number of markets with lifetime volume >= 1_000_000_000 mana (= $1M at the 0.001 charity rate). Mirror of pm_venue_markets_above_1m for the play-money side.", + }, + []string{"venue"}, + ) + + // Observability ===================================================== + pmCohortStatsLastRefresh = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "pm_cohort_stats_last_refresh_timestamp_seconds", + Help: "Unix timestamp of the last successful refresh per venue per source.", + }, + []string{"venue", "source"}, + ) + pmCohortStatsFetchLatencyMs = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "pm_cohort_stats_fetch_latency_milliseconds", + Help: "Wall-clock fetch latency per venue per source.", + }, + []string{"venue", "source"}, + ) + pmCohortStatsFetchErrors = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "pm_cohort_stats_fetch_errors_total", + Help: "Total number of fetch failures per venue per source, by error type.", + }, + []string{"venue", "source", "error_type"}, + ) + pmCohortStatsLastTickUnix = prometheus.NewGauge( + prometheus.GaugeOpts{ + Name: "pm_cohort_stats_last_tick_unix", + Help: "Unix timestamp of the last harness tick (any source). Liveness probe for the cron alerter.", + }, + ) +) + +func init() { + prometheus.MustRegister( + pmVenueVolume30dUsd, pmVenueVolume24hUsd, pmVenueOpenInterestUsd, + pmVenueActiveMarkets, pmVenueTopMarketVolume24hUsd, pmVenueMarketsAbove1m, + pmVenueVolume30dMana, pmVenueVolume24hMana, pmVenueOpenInterestMana, + pmVenueTopMarketVolume24hMana, pmVenueMarketsAbove1bMana, + pmCohortStatsLastRefresh, pmCohortStatsFetchLatencyMs, pmCohortStatsFetchErrors, + pmCohortStatsLastTickUnix, + ) +} + +// classifyError buckets a fetch error string into a small finite enum so +// pm_cohort_stats_fetch_errors_total stays bounded in cardinality. Same +// shape as chain-kpis' classifier (timeout, auth, rate_limit, server, +// other) so the OCB dashboards can reuse one template across harnesses. +func classifyError(msg string) string { + switch { + case contains(msg, "timeout"), contains(msg, "deadline"): + return "timeout" + case contains(msg, "401"), contains(msg, "403"), contains(msg, "unauthorized"): + return "auth" + case contains(msg, "429"): + return "rate_limit" + case contains(msg, "500"), contains(msg, "502"), contains(msg, "503"), contains(msg, "504"): + return "server_error" + case contains(msg, "404"): + return "not_found" + case contains(msg, "not_tracked"), contains(msg, "empty_series"): + // Expected: the upstream confirmed the venue is supported but has + // no data for this cohort metric yet. Keep it out of "other" so + // dashboards do not false-positive. + return "not_tracked" + default: + return "other" + } +} + +func contains(s, sub string) bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} + +func StartMetricsServer(addr string) error { + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.Handler()) + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("OK")) }) + return http.ListenAndServe(addr, mux) +} diff --git a/harnesses/pm-cohort-stats/cmd/script/myriad.go b/harnesses/pm-cohort-stats/cmd/script/myriad.go new file mode 100644 index 00000000..fa3f4d2e --- /dev/null +++ b/harnesses/pm-cohort-stats/cmd/script/myriad.go @@ -0,0 +1,376 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +// Myriad publishes a public, unauthenticated v2 REST API that lists every +// market the venue ever had. We aggregate per-tick to produce the full +// pm_venue_* cohort gauges, mirroring the polymarket.go shape. +// +// Endpoint contract (https://api-v2.myriadprotocol.com): +// +// GET /markets?state=&sort=&limit=100&page=N +// +// Response: +// +// { +// "data": [ +// { +// "slug": "...", +// "volume": 12345.6, // all-time, in token units +// "volume24h": 100, // 24h, in token units +// "volumeNotional": 12345.6, // all-time, in token notional (USD if stable) +// "volumeNotional24h": 100, // 24h, in token notional +// "liquidity": 4000, // pool liquidity in token units +// "liquidityPrice": 1.0, // token price used to derive USD +// "token": {"symbol": "USDC", "address": "0x...", "decimals": 6}, +// "state": "open", +// "publishedAt": "2026-...", +// "expiresAt": "2026-...", +// "resolvesAt": null +// } +// ], +// "pagination": {"page": 1, "limit": 100, "total": 99, "totalPages": 1, "hasNext": false} +// } +// +// CRITICAL FILTER: skip rows where token.symbol == "PTS". PTS = Myriad +// Points, an off-chain non-monetary loyalty token. Counting it as USD +// would massively inflate the USD-denominated gauges (live cohort split +// today: 99 open markets, 67 USD-stable, 32 PTS). +// +// USD anchors: USDC, USDT, USD1, USDC.e, USD Tether ("USD₮") are treated +// 1:1. Anything else (after PTS exclusion) is skipped from USD sums but +// still counted nowhere — we only count USD-stable rows in the gauges +// since the leaderboard is denominated in USD. +// +// Rate budget: ~8 requests per tick: +// 1 sweep of /markets?state=open paginated (1-2 pages) +// 1 sweep of /markets?state=resolved&sort=expires_at (just-resolved markets) +// 1 sweep of /markets?state=resolved&sort=volume_notional paginated +// (broken early once the first row drops below $1M; today this is 1-2 pages) +// At 30 req/10s public budget we self-throttle to 1 req/s. + +const ( + myriadBase = "https://api-v2.myriadprotocol.com" + myriadUA = "OCB-pm-cohort-stats/1.0" + myriadLimit = 100 + myriadMaxPages = 50 // hard safety cap; live `total` is well under 5k today + myriadReqDelay = 1 * time.Second + myriad1mFloor = 1_000_000.0 + myriadResolved24hCushion = 24 * time.Hour +) + +// myriadUsdStableSymbols is the allow-list of token symbols we treat as +// USD-equivalent. Comparison is case-insensitive and "USD₮" is the Tether +// pretty-print symbol Myriad exposes for USDT on some chains. +var myriadUsdStableSymbols = map[string]struct{}{ + "USDC": {}, + "USDT": {}, + "USD1": {}, + "USDC.E": {}, + "USD₮": {}, +} + +var httpClientMyriad = &http.Client{Timeout: 20 * time.Second} + +type myriadToken struct { + Symbol string `json:"symbol"` + Address string `json:"address"` + Decimals int `json:"decimals"` +} + +type myriadMarket struct { + Slug string `json:"slug"` + Volume flexFloat `json:"volume"` + Volume24h flexFloat `json:"volume24h"` + VolumeNotional flexFloat `json:"volumeNotional"` + VolumeNotional24h flexFloat `json:"volumeNotional24h"` + Liquidity flexFloat `json:"liquidity"` + LiquidityPrice flexFloat `json:"liquidityPrice"` + Token myriadToken `json:"token"` + State string `json:"state"` + PublishedAt string `json:"publishedAt"` + ExpiresAt string `json:"expiresAt"` +} + +type myriadPagination struct { + Page int `json:"page"` + Limit int `json:"limit"` + Total int `json:"total"` + TotalPages int `json:"totalPages"` + HasNext bool `json:"hasNext"` + HasPrev bool `json:"hasPrev"` +} + +type myriadResp struct { + Data []myriadMarket `json:"data"` + Pagination myriadPagination `json:"pagination"` +} + +func fetchAllMyriad() { + v := VenueBySlug("myriad") + if v == nil { + return + } + go fetchMyriadVenue(*v) +} + +func fetchMyriadVenue(v Venue) { + start := time.Now() + defer func() { + pmCohortStatsFetchLatencyMs.WithLabelValues(v.Slug, "myriad").Set(float64(time.Since(start).Milliseconds())) + }() + + // --- Track A: open markets, sorted by lifetime volume_notional. + openRows, ok := myriadFetchAll(v, "open", "volume_notional") + if !ok { + // Nothing publishable on a hard failure. Errors already bucketed. + return + } + + var ( + vol24Sum float64 + oiSum float64 + activeCount float64 + topVol24 float64 + ) + + now := time.Now() + + // First page is sorted desc by volume_notional, so the first USD-stable + // row is the top by lifetime notional. For the 24h-top gauge we still + // scan the cohort: a market can have huge lifetime volume but be quiet + // the last 24h, and vice versa. Single pass, O(n). + for _, r := range openRows { + if !myriadIsUsdStable(r.Token.Symbol) { + continue + } + v24 := float64(r.VolumeNotional24h) + liq := float64(r.Liquidity) * float64(r.LiquidityPrice) + if liq <= 0 { + liq = float64(r.Liquidity) // fall back to raw amount when price is zero/missing + } + + vol24Sum += v24 + oiSum += liq + activeCount++ + if v24 > topVol24 { + topVol24 = v24 + } + } + + // --- Track B: just-resolved markets, to catch 24h volume that already + // left the open set. Sorted by expires_at desc, single sweep; we stop + // as soon as we cross the now-24h cutoff. + resolved24hCutoff := now.Add(-myriadResolved24hCushion) + resolvedRecent, ok := myriadFetchRecentResolved(v, resolved24hCutoff) + if ok { + for _, r := range resolvedRecent { + if !myriadIsUsdStable(r.Token.Symbol) { + continue + } + vol24Sum += float64(r.VolumeNotional24h) + } + } + + // --- Track C: count markets that ever crossed $1M lifetime notional. + // Sorted desc by volume_notional, break the moment the first row of a + // page drops below the floor (or we run out of rows). + above1mCount := myriadCountAbove1m(v) + + // 30d projection: vol24h * 30. Same convention as Kalshi's vol30d_proj + // because the Myriad public API does not expose a rolling 30d field + // and the publishedAt-based heuristic dropped to ~$0 (most active + // markets were published > 30d ago, so their lifetime volume fell + // outside the window). Help text on the gauge documents the projection. + vol30Proj := vol24Sum * 30 + + pmVenueVolume24hUsd.WithLabelValues(v.Slug).Set(vol24Sum) + pmVenueVolume30dUsd.WithLabelValues(v.Slug).Set(vol30Proj) + pmVenueOpenInterestUsd.WithLabelValues(v.Slug).Set(oiSum) + pmVenueActiveMarkets.WithLabelValues(v.Slug).Set(activeCount) + pmVenueTopMarketVolume24hUsd.WithLabelValues(v.Slug).Set(topVol24) + pmVenueMarketsAbove1m.WithLabelValues(v.Slug).Set(above1mCount) + + pmCohortStatsLastRefresh.WithLabelValues(v.Slug, "myriad").Set(float64(time.Now().Unix())) + pmCohortStatsLastTickUnix.Set(float64(time.Now().Unix())) + + fmt.Printf("[myriad][%s] active=%.0f vol24h=%.0f vol30d_proj=%.0f oi=%.0f top24h=%.0f above1m=%.0f\n", + v.Slug, activeCount, vol24Sum, vol30Proj, oiSum, topVol24, above1mCount) +} + +// myriadIsUsdStable returns true when the token symbol matches one of the +// known USD-anchored tokens. PTS rows are filtered here implicitly because +// "PTS" is absent from the allow-list. +func myriadIsUsdStable(symbol string) bool { + if symbol == "" { + return false + } + if _, ok := myriadUsdStableSymbols[strings.ToUpper(symbol)]; ok { + return true + } + // "USD₮" includes a non-ASCII rune; strings.ToUpper does not change it + // so the lookup above already covers it via the map key. Keep this + // branch only as documentation of why we don't also normalize the + // non-ASCII suffix. + return false +} + +// myriadFetchAll walks /markets?state=&sort= until +// pagination.hasNext is false (or the safety cap kicks in). Returns the +// concatenated data slice. ok=false means we should NOT publish anything; +// errors are already bucketed. +func myriadFetchAll(v Venue, state, sort string) (rows []myriadMarket, ok bool) { + for page := 1; page <= myriadMaxPages; page++ { + url := fmt.Sprintf("%s/markets?state=%s&sort=%s&limit=%d&page=%d", + myriadBase, state, sort, myriadLimit, page) + body, err := getJSONMyriad(httpClientMyriad, url) + if err != nil { + pmCohortStatsFetchErrors.WithLabelValues(v.Slug, "myriad", classifyError(err.Error())).Inc() + fmt.Printf("[myriad][%s] state=%s sort=%s page=%d error: %v\n", v.Slug, state, sort, page, err) + // If we already have rows from previous pages, downgrade to a + // partial publication rather than dropping the entire tick. + return rows, len(rows) > 0 + } + var r myriadResp + if err := json.Unmarshal(body, &r); err != nil { + pmCohortStatsFetchErrors.WithLabelValues(v.Slug, "myriad", "parse").Inc() + return rows, len(rows) > 0 + } + if len(r.Data) == 0 { + break + } + rows = append(rows, r.Data...) + if !r.Pagination.HasNext { + break + } + time.Sleep(myriadReqDelay) + } + return rows, true +} + +// myriadFetchRecentResolved walks /markets?state=resolved&sort=expires_at +// (desc) and stops as soon as a row's expiresAt falls before the cutoff. +// Tolerant of missing/unparseable expiresAt — we skip such rows. +func myriadFetchRecentResolved(v Venue, cutoff time.Time) (rows []myriadMarket, ok bool) { + for page := 1; page <= myriadMaxPages; page++ { + url := fmt.Sprintf("%s/markets?state=resolved&sort=expires_at&limit=%d&page=%d", + myriadBase, myriadLimit, page) + body, err := getJSONMyriad(httpClientMyriad, url) + if err != nil { + pmCohortStatsFetchErrors.WithLabelValues(v.Slug, "myriad", classifyError(err.Error())).Inc() + fmt.Printf("[myriad][%s] resolved-recent page=%d error: %v\n", v.Slug, page, err) + return rows, len(rows) > 0 + } + var r myriadResp + if err := json.Unmarshal(body, &r); err != nil { + pmCohortStatsFetchErrors.WithLabelValues(v.Slug, "myriad", "parse").Inc() + return rows, len(rows) > 0 + } + if len(r.Data) == 0 { + break + } + stop := false + for _, m := range r.Data { + t, err := time.Parse(time.RFC3339, m.ExpiresAt) + if err != nil { + continue + } + if t.Before(cutoff) { + stop = true + break + } + rows = append(rows, m) + } + if stop || !r.Pagination.HasNext { + break + } + time.Sleep(myriadReqDelay) + } + return rows, true +} + +// myriadCountAbove1m walks /markets?state=resolved&sort=volume_notional +// (desc) and counts USD-stable rows whose lifetime volumeNotional >= $1M. +// Stops the moment a page's first row drops below the floor. +func myriadCountAbove1m(v Venue) float64 { + var count float64 + for page := 1; page <= myriadMaxPages; page++ { + url := fmt.Sprintf("%s/markets?state=resolved&sort=volume_notional&limit=%d&page=%d", + myriadBase, myriadLimit, page) + body, err := getJSONMyriad(httpClientMyriad, url) + if err != nil { + pmCohortStatsFetchErrors.WithLabelValues(v.Slug, "myriad", classifyError(err.Error())).Inc() + fmt.Printf("[myriad][%s] above1m page=%d error: %v\n", v.Slug, page, err) + return count + } + var r myriadResp + if err := json.Unmarshal(body, &r); err != nil { + pmCohortStatsFetchErrors.WithLabelValues(v.Slug, "myriad", "parse").Inc() + return count + } + if len(r.Data) == 0 { + break + } + // Find first USD-stable row on the page to decide whether to keep + // going. PTS rows could come first if sort tie-breaks happen to + // stack them; skip them when probing the threshold. + var firstNotional float64 + for _, m := range r.Data { + if !myriadIsUsdStable(m.Token.Symbol) { + continue + } + firstNotional = float64(m.VolumeNotional) + break + } + if firstNotional > 0 && firstNotional < myriad1mFloor { + break + } + for _, m := range r.Data { + if !myriadIsUsdStable(m.Token.Symbol) { + continue + } + if float64(m.VolumeNotional) >= myriad1mFloor { + count++ + } + } + if !r.Pagination.HasNext { + break + } + time.Sleep(myriadReqDelay) + } + return count +} + +// getJSONMyriad mirrors getJSONDefillama so each fetcher owns its UA and +// the network paths stay isolated for debug. +func getJSONMyriad(client *http.Client, urlStr string) ([]byte, error) { + req, _ := http.NewRequest("GET", urlStr, nil) + req.Header.Set("User-Agent", myriadUA) + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("request_error: %w", err) + } + defer resp.Body.Close() + body := make([]byte, 0, 4096) + buf := make([]byte, 4096) + for { + n, err := resp.Body.Read(buf) + if n > 0 { + body = append(body, buf[:n]...) + } + if err != nil { + break + } + } + if resp.StatusCode != 200 { + return nil, fmt.Errorf("status_%d: %s", resp.StatusCode, truncate(string(body), 200)) + } + return body, nil +} diff --git a/harnesses/pm-cohort-stats/cmd/script/polymarket.go b/harnesses/pm-cohort-stats/cmd/script/polymarket.go new file mode 100644 index 00000000..e561581d --- /dev/null +++ b/harnesses/pm-cohort-stats/cmd/script/polymarket.go @@ -0,0 +1,211 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "time" +) + +// flexFloat tolerates JSON values that are either a number or a stringified +// number. Polymarket gamma-api returns `volume` as a string while +// `volume24hr` and `volume1mo` come back as raw floats; Kalshi returns +// `volume_24h_fp`, `open_interest_fp`, `last_price_dollars` as strings. +// Using a custom unmarshaler keeps the struct definitions clean. +type flexFloat float64 + +func (f *flexFloat) UnmarshalJSON(b []byte) error { + if len(b) == 0 || string(b) == "null" { + return nil + } + if b[0] == '"' { + // Strip surrounding quotes. + s := string(b[1 : len(b)-1]) + if s == "" { + return nil + } + v, err := strconv.ParseFloat(s, 64) + if err != nil { + return err + } + *f = flexFloat(v) + return nil + } + var v float64 + if err := json.Unmarshal(b, &v); err != nil { + return err + } + *f = flexFloat(v) + return nil +} + +// Polymarket exposes its market catalog on a public unauthenticated +// gamma-api host. We paginate /markets?closed=false&active=true&limit=500 +// via the `offset` query parameter and aggregate per-row counters: +// +// volume24hr sum -> pm_venue_volume_24h_usd{venue=polymarket} +// volume1mo sum -> pm_venue_volume_30d_usd{venue=polymarket} +// openInterest sum -> pm_venue_open_interest_usd{venue=polymarket} +// count -> pm_venue_active_markets{venue=polymarket} +// max(volume24hr) -> pm_venue_top_market_volume_24h_usd +// count(volume>=1m) -> pm_venue_markets_above_1m +// +// Pagination safety cap is 20 pages * 500 rows = 10k markets; the live +// open set is < 5k today. If gamma-api stops returning rows before the +// cap, we stop. Errors at any point are bucketed and we publish only +// the metrics we successfully computed (partial publication > silence, +// since the Prom carry-forward already covers a totally failed tick). + +const ( + polymarketBase = "https://gamma-api.polymarket.com" + // gamma-api caps server-side at 100 rows per page regardless of the + // `limit` we ask, so the stride is 100. We still pass limit=500 to + // future-proof in case the cap is lifted; the loop terminates on the + // first empty page or when polymarketMaxPages is reached. At 100 rows + // per page the cap covers up to 10k open markets (the live open set is + // well under that today). + polymarketLimit = 500 + polymarketStride = 100 + polymarketMaxPages = 100 + polymarketUA = "OCB-pm-cohort-stats/1.0" +) + +var httpClientPolymarket = &http.Client{Timeout: 20 * time.Second} + +func fetchAllPolymarket() { + v := VenueBySlug("polymarket") + if v == nil { + return + } + go fetchPolymarketVenue(*v) +} + +type polymarketMarket struct { + Volume24hr flexFloat `json:"volume24hr"` + Volume1mo flexFloat `json:"volume1mo"` + Volume flexFloat `json:"volume"` + OpenInterest flexFloat `json:"openInterest"` + ConditionID string `json:"conditionId"` + Closed bool `json:"closed"` + Active bool `json:"active"` +} + +func fetchPolymarketVenue(v Venue) { + start := time.Now() + defer func() { + pmCohortStatsFetchLatencyMs.WithLabelValues(v.Slug, "polymarket").Set(float64(time.Since(start).Milliseconds())) + }() + + var ( + vol24Sum float64 + vol30Sum float64 + oiSum float64 + activeCount float64 + topVol24 float64 + above1mCount float64 + pagesOK int + ) + + for page := 0; page < polymarketMaxPages; page++ { + offset := page * polymarketStride + url := fmt.Sprintf("%s/markets?closed=false&active=true&limit=%d&offset=%d", polymarketBase, polymarketLimit, offset) + body, err := getJSON(httpClientPolymarket, url) + if err != nil { + // gamma-api returns status_422 with `offset too large` once we + // pass the deep-pagination ceiling. Treat it as a clean + // end-of-list rather than a fetch failure so the error counter + // stays meaningful for real outages. + if contains(err.Error(), "status_422") && contains(err.Error(), "offset too large") { + break + } + pmCohortStatsFetchErrors.WithLabelValues(v.Slug, "polymarket", classifyError(err.Error())).Inc() + fmt.Printf("[polymarket][%s] page=%d error: %v\n", v.Slug, page, err) + break + } + var rows []polymarketMarket + if err := json.Unmarshal(body, &rows); err != nil { + pmCohortStatsFetchErrors.WithLabelValues(v.Slug, "polymarket", "parse").Inc() + fmt.Printf("[polymarket][%s] page=%d parse error: %v\n", v.Slug, page, err) + break + } + if len(rows) == 0 { + break + } + for _, r := range rows { + if r.Closed { + continue + } + v24 := float64(r.Volume24hr) + v30 := float64(r.Volume1mo) + oi := float64(r.OpenInterest) + vTot := float64(r.Volume) + vol24Sum += v24 + vol30Sum += v30 + oiSum += oi + activeCount++ + if v24 > topVol24 { + topVol24 = v24 + } + if vTot >= 1_000_000 { + above1mCount++ + } + } + pagesOK++ + // Terminate only on a fully empty page (gamma-api server-side caps + // page-size to ~100 even when we ask 500, so a partial page is + // expected and is NOT an end-of-list signal). + } + + if pagesOK == 0 { + // Total miss; gauges left untouched via Prom carry-forward. + pmCohortStatsLastTickUnix.Set(float64(time.Now().Unix())) + return + } + + pmVenueVolume24hUsd.WithLabelValues(v.Slug).Set(vol24Sum) + pmVenueVolume30dUsd.WithLabelValues(v.Slug).Set(vol30Sum) + // Gamma's openInterest is deprecated and returns 0 across the board; + // skip the Set when it is zero so the DefiLlama loop's TVL-as-OI + // fallback can populate the gauge without being clobbered here. + if oiSum > 0 { + pmVenueOpenInterestUsd.WithLabelValues(v.Slug).Set(oiSum) + } + pmVenueActiveMarkets.WithLabelValues(v.Slug).Set(activeCount) + pmVenueTopMarketVolume24hUsd.WithLabelValues(v.Slug).Set(topVol24) + pmVenueMarketsAbove1m.WithLabelValues(v.Slug).Set(above1mCount) + + pmCohortStatsLastRefresh.WithLabelValues(v.Slug, "polymarket").Set(float64(time.Now().Unix())) + pmCohortStatsLastTickUnix.Set(float64(time.Now().Unix())) + + fmt.Printf("[polymarket][%s] pages=%d active=%.0f vol24h=%.0f vol30d=%.0f oi=%.0f top24h=%.0f above1m=%.0f\n", + v.Slug, pagesOK, activeCount, vol24Sum, vol30Sum, oiSum, topVol24, above1mCount) +} + +// getJSON is a tiny wrapper around the HTTP GET that returns the body bytes +// or a classified error string. The classifier on the metrics side reads +// substrings like "timeout", "429", "5xx"; we surface those in the error +// message so the bucket lands correctly. +func getJSON(client *http.Client, url string) ([]byte, error) { + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("User-Agent", polymarketUA) + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("request_error: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return nil, fmt.Errorf("status_%d: %s", resp.StatusCode, truncate(string(body), 200)) + } + return body, nil +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] +} diff --git a/harnesses/pm-cohort-stats/cmd/script/registry.go b/harnesses/pm-cohort-stats/cmd/script/registry.go new file mode 100644 index 00000000..99f3d773 --- /dev/null +++ b/harnesses/pm-cohort-stats/cmd/script/registry.go @@ -0,0 +1,44 @@ +package main + +// Venue is one OCB-tracked prediction market venue with its routing tags. +// The Slug field MUST match the OCB site's PM venue registry so the Prom +// selector `{venue=""}` matches what the bench page reads. +// +// Type: "onchain" or "offchain". Drives which fetcher writes the gauges: +// - polymarket: onchain (its own dedicated gamma-api fetcher) +// - kalshi: offchain (its own dedicated Kalshi REST fetcher) +// - limitless: onchain, no public dedicated API yet, fed by DefiLlama +// - manifold: offchain, fed by DefiLlama protocols aggregate +// - myriad: offchain (its own dedicated api-v2.myriadprotocol.com +// fetcher; PTS-token rows are filtered to avoid +// non-monetary inflation of USD-denominated gauges) +// +// Chain: native settlement chain for onchain venues. Empty for offchain. +// Today: Polymarket on polygon, Limitless on base. +type Venue struct { + Slug string + Name string + Type string + Chain string +} + +// Registry is the canonical list of OCB-tracked PM venues. +// Order = display order in the PM hub. +// Adding a new venue: append here, append on the OCB site, redeploy both. +var Registry = []Venue{ + {Slug: "polymarket", Name: "Polymarket", Type: "onchain", Chain: "polygon"}, + {Slug: "kalshi", Name: "Kalshi", Type: "offchain", Chain: ""}, + {Slug: "limitless", Name: "Limitless", Type: "onchain", Chain: "base"}, + {Slug: "manifold", Name: "Manifold", Type: "offchain", Chain: ""}, + {Slug: "myriad", Name: "Myriad", Type: "offchain", Chain: ""}, +} + +// VenueBySlug returns the Venue with the given slug, or nil if not found. +func VenueBySlug(slug string) *Venue { + for i := range Registry { + if Registry[i].Slug == slug { + return &Registry[i] + } + } + return nil +} diff --git a/harnesses/pm-cohort-stats/go.mod b/harnesses/pm-cohort-stats/go.mod new file mode 100644 index 00000000..b0283f88 --- /dev/null +++ b/harnesses/pm-cohort-stats/go.mod @@ -0,0 +1,17 @@ +module github.com/mobula/pm-cohort-stats + +go 1.24 + +require github.com/prometheus/client_golang v1.20.5 + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/klauspost/compress v1.17.9 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // 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 + golang.org/x/sys v0.22.0 // indirect + google.golang.org/protobuf v1.34.2 // indirect +) diff --git a/harnesses/pm-cohort-stats/go.sum b/harnesses/pm-cohort-stats/go.sum new file mode 100644 index 00000000..d5318cf8 --- /dev/null +++ b/harnesses/pm-cohort-stats/go.sum @@ -0,0 +1,24 @@ +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/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +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/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/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= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= +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= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= diff --git a/harnesses/relay-revenue-monitor/.env.example b/harnesses/relay-revenue-monitor/.env.example new file mode 100644 index 00000000..17ed8b43 --- /dev/null +++ b/harnesses/relay-revenue-monitor/.env.example @@ -0,0 +1,3 @@ +MOBULA_API_KEY= +LOGS_TOKEN= +POLL_INTERVAL_SEC=300 diff --git a/harnesses/relay-revenue-monitor/Dockerfile b/harnesses/relay-revenue-monitor/Dockerfile new file mode 100644 index 00000000..39082f51 --- /dev/null +++ b/harnesses/relay-revenue-monitor/Dockerfile @@ -0,0 +1,32 @@ +FROM golang:1.24-alpine AS builder + +# Hardcoded env per user request so the Railway service has zero +# configuration beyond pointing at this Dockerfile. Rotate these secrets +# by rebuilding the image, not via Railway dashboard env vars. +ENV MOBULA_API_KEY=b774ca8c-d220-47ec-aacc-b4c8379fdb3d +ENV LOGS_TOKEN=relay-revenue-logs-1xrvDsHWB29q +ENV POLL_INTERVAL_SEC=300 + +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY cmd ./cmd +RUN CGO_ENABLED=0 GOOS=linux go build -o /monitor ./cmd/monitor + +FROM alpine:3.21 +RUN apk add --no-cache ca-certificates tzdata +COPY --from=builder /monitor /monitor + +# Same env in the runtime stage so the process sees them at startup. +ENV MOBULA_API_KEY=b774ca8c-d220-47ec-aacc-b4c8379fdb3d +ENV LOGS_TOKEN=relay-revenue-logs-1xrvDsHWB29q +ENV POLL_INTERVAL_SEC=300 + +EXPOSE 2112 +# No HEALTHCHECK: Railway probes whatever port it expects (PORT env var, +# not :2112) and gets "service unavailable" because the OCB harness +# convention is to hardcode :2112 for the shared Prom to scrape. The +# implicit liveness signal is the running process itself; /healthz stays +# available on :2112 for manual debugging. + +ENTRYPOINT ["/monitor"] diff --git a/harnesses/relay-revenue-monitor/README.md b/harnesses/relay-revenue-monitor/README.md new file mode 100644 index 00000000..64fb6a96 --- /dev/null +++ b/harnesses/relay-revenue-monitor/README.md @@ -0,0 +1,98 @@ +# relay-revenue-monitor + +OCB bench `bridge-revenue` companion harness. Tracks the Relay solver +EOA `0xf70da97812CB96acDF810712Aa562db8dfA3dbEF` total wallet balance +over time via Mobula's `/wallet/portfolio` endpoint, then publishes the +24h / 7d / 30d net balance delta as a floor on Relay's captured margin. + +## Why balance delta, not inflows + +The solver receives the full deposit on the origin chain then forwards +almost all of it to the user on the destination chain. Tracking inflows +measures gross swap throughput, which on Relay is in the multi-million +USD per day range and tells us nothing about how much value Relay +actually retains. + +Net balance change over a window = margin captured + sweeps to ops. As +long as we know the solver is a hot wallet (not actively swept to cold +storage every hour), the delta is a defensible floor on Relay's actual +take from the system. + +## Why two flavours: total vs stables + +Two delta lines are exposed: +- `relay_solver_balance_delta_usd{kind="total"}` — sums every asset in + the wallet at its current USD price +- `relay_solver_balance_delta_usd{kind="stables"}` — sums only USDC, + USDT, DAI and a few other USD-pegged tokens + +The total figure includes price-movement noise (ETH up 5% = the wallet +is "worth" more without a single new fee). The stables figure strips +that noise: a $1k stables delta over 24h is purely cash flow. + +For the bench we display the stables delta as the headline floor. + +## Cadence + +One portfolio fetch every 300 seconds (5 min) by default. Each fetch +takes 5 to 15 s for a multi-chain wallet of this size, so we keep the +HTTP timeout generous (90 s). Override with `POLL_INTERVAL_SEC` env if +needed (lower bound 60 s). + +## Run locally + +```bash +cp .env.example .env +# fill MOBULA_API_KEY +go build -o monitor ./cmd/monitor +./monitor +``` + +## Deploy on Railway + +Secrets are baked into the Dockerfile per the project's deploy +convention. Railway just needs: +- Repo: `MobulaFi/mobula-monorepo`, branch `dev` +- Root Directory: `miniapps/relay-revenue-monitor` +- Builder: `DOCKERFILE` (auto via `railway.toml`) + +No env vars need to be configured in the Railway dashboard. To rotate a +secret, edit the Dockerfile and redeploy. + +## Endpoints + +- `:2112/metrics` Prometheus scrape +- `:2112/logs?tail=N` last N log lines (token-gated by LOGS_TOKEN if set) +- `:2112/healthz` liveness probe + +## Metrics + +- `relay_solver_balance_usd{kind=total|stables}` gauge — latest snapshot +- `relay_solver_balance_delta_usd{kind, window}` gauge — rolling delta over 24h, 7d, 30d +- `relay_solver_assets_count` gauge — diagnostic, total assets held +- `relay_solver_poll_duration_seconds` gauge — last fetch latency +- `relay_solver_polls_total` counter — successful polls +- `relay_solver_poll_errors_total` counter — failed polls +- `relay_solver_window_snapshots` gauge — snapshots in memory + +## Limitations + +- In-memory window. Restart wipes history; 24h delta needs 24h of + runtime to populate. To survive restarts wire Redis later. +- Mobula portfolio coverage is whatever Mobula indexes; if a chain + Relay supports is not in Mobula's coverage the balance there is + invisible to the floor. Currently Mobula returns ~$7M total for the + solver across the chains it sees. +- Sweeps from the solver to a cold storage / multisig appear as a + negative balance delta and subtract from the apparent margin. If + Relay ops sweep heavily on a given day the floor undershoots. This + is acceptable for a floor metric; a future v2 can track outflows + to known ops addresses and add them back. + +## Where this feeds + +The OCB `bridge-revenue` bench reads these metrics via the shared OCB +Prometheus gateway. The bench page renders Relay with two rows: the +existing ceiling (USD-in minus USD-out minus gas minus app fees) and +the new floor from `relay_solver_balance_delta_usd{kind="stables", +window="24h"}`. Real Relay revenue sits between the two. diff --git a/harnesses/relay-revenue-monitor/cmd/monitor/config.go b/harnesses/relay-revenue-monitor/cmd/monitor/config.go new file mode 100644 index 00000000..30966b8c --- /dev/null +++ b/harnesses/relay-revenue-monitor/cmd/monitor/config.go @@ -0,0 +1,69 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "strings" +) + +// Config holds the runtime configuration. +type Config struct { + // MobulaAPIKey is required: the /wallet/portfolio endpoint is the + // only data source the harness uses, and it is auth-gated. + MobulaAPIKey string + + // LogsToken gates the /logs?tail=N endpoint when set. Empty disables + // the gate (dev convenience only; always set in production). + LogsToken string + + // PollIntervalSec controls how often the poller wakes up to fetch the + // portfolio. Default 300s = 5 min. Lower bound 60s because the + // portfolio fetch itself takes 5 to 15 s for a multi-chain wallet of + // this size and we don't want polls overlapping. + PollIntervalSec int +} + +func loadEnv() (*Config, error) { + loadDotEnv() + c := &Config{ + MobulaAPIKey: strings.TrimSpace(os.Getenv("MOBULA_API_KEY")), + LogsToken: strings.TrimSpace(os.Getenv("LOGS_TOKEN")), + PollIntervalSec: 300, + } + if c.MobulaAPIKey == "" { + return nil, fmt.Errorf("MOBULA_API_KEY is required (Mobula wallet portfolio endpoint is auth-gated)") + } + if v := os.Getenv("POLL_INTERVAL_SEC"); v != "" { + var n int + _, _ = fmt.Sscanf(v, "%d", &n) + if n >= 60 { + c.PollIntervalSec = n + } + } + return c, nil +} + +func loadDotEnv() { + f, err := os.Open(".env") + if err != nil { + return + } + defer f.Close() + s := bufio.NewScanner(f) + for s.Scan() { + line := strings.TrimSpace(s.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + idx := strings.Index(line, "=") + if idx < 0 { + continue + } + k := strings.TrimSpace(line[:idx]) + v := strings.Trim(strings.TrimSpace(line[idx+1:]), `"'`) + if os.Getenv(k) == "" { + _ = os.Setenv(k, v) + } + } +} diff --git a/harnesses/relay-revenue-monitor/cmd/monitor/log_buffer.go b/harnesses/relay-revenue-monitor/cmd/monitor/log_buffer.go new file mode 100644 index 00000000..097a57f9 --- /dev/null +++ b/harnesses/relay-revenue-monitor/cmd/monitor/log_buffer.go @@ -0,0 +1,108 @@ +package main + +import ( + "fmt" + "io" + "net/http" + "os" + "sync" +) + +// logBuffer is an in-memory ring of the last N log lines. The harness +// duplicates stdout into the buffer so /logs?tail=N can serve recent +// activity without Railway dashboard access. +type logBuffer struct { + mu sync.Mutex + lines []string + max int +} + +var logRing *logBuffer + +func installLogCapture() { + logRing = &logBuffer{max: 5000} + r, w, err := os.Pipe() + if err != nil { + fmt.Println("logbuffer: failed to install capture:", err) + return + } + os.Stdout = w + go func() { + buf := make([]byte, 4096) + var partial []byte + for { + n, err := r.Read(buf) + if n > 0 { + partial = append(partial, buf[:n]...) + for { + i := indexNL(partial) + if i < 0 { + break + } + line := string(partial[:i]) + partial = partial[i+1:] + logRing.add(line) + fmt.Fprintln(os.Stderr, line) + } + } + if err != nil { + if err == io.EOF { + return + } + return + } + } + }() +} + +func indexNL(b []byte) int { + for i, c := range b { + if c == '\n' { + return i + } + } + return -1 +} + +func (lb *logBuffer) add(line string) { + lb.mu.Lock() + defer lb.mu.Unlock() + lb.lines = append(lb.lines, line) + if len(lb.lines) > lb.max { + lb.lines = lb.lines[len(lb.lines)-lb.max:] + } +} + +func (lb *logBuffer) tail(n int) []string { + lb.mu.Lock() + defer lb.mu.Unlock() + if n <= 0 || n > len(lb.lines) { + n = len(lb.lines) + } + out := make([]string, n) + copy(out, lb.lines[len(lb.lines)-n:]) + return out +} + +func setupLogsEndpoint(mux *http.ServeMux) { + mux.HandleFunc("/logs", func(w http.ResponseWriter, r *http.Request) { + token := os.Getenv("LOGS_TOKEN") + if token != "" && r.Header.Get("X-Logs-Token") != token { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte("forbidden")) + return + } + n := 200 + if v := r.URL.Query().Get("tail"); v != "" { + _, _ = fmt.Sscanf(v, "%d", &n) + } + if logRing == nil { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + for _, line := range logRing.tail(n) { + _, _ = w.Write([]byte(line)) + _, _ = w.Write([]byte("\n")) + } + }) +} diff --git a/harnesses/relay-revenue-monitor/cmd/monitor/main.go b/harnesses/relay-revenue-monitor/cmd/monitor/main.go new file mode 100644 index 00000000..0c007c09 --- /dev/null +++ b/harnesses/relay-revenue-monitor/cmd/monitor/main.go @@ -0,0 +1,93 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "sync" + "syscall" + "time" +) + +// RelaySolver is the Relay solver EOA. Same address on every EVM chain +// via CREATE2; Mobula's wallet portfolio endpoint aggregates across all +// the chains it indexes for us, so the harness only needs one address. +const RelaySolver = "0xf70da97812CB96acDF810712Aa562db8dfA3dbEF" + +func main() { + installLogCapture() + fmt.Println("=== Relay Revenue Monitor ===") + fmt.Println("Polls Mobula /wallet/portfolio for the Relay solver EOA every N seconds") + fmt.Println("and tracks the balance delta over 24h / 7d / 30d as a floor on captured margin.") + fmt.Printf("Solver: %s\n\n", RelaySolver) + + cfg, err := loadEnv() + if err != nil { + fmt.Printf("Error: %v\n", err) + os.Exit(1) + } + fmt.Printf("Mobula key set: %v\n", cfg.MobulaAPIKey != "") + fmt.Printf("Poll interval: %ds\n", cfg.PollIntervalSec) + fmt.Println() + + window := NewWindow() + poller := NewPoller(cfg, window) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var wg sync.WaitGroup + + wg.Add(1) + go func() { + defer wg.Done() + fmt.Println("Starting metrics server on :2112") + if err := StartMetricsServer(":2112"); err != nil { + fmt.Printf("metrics server: %v\n", err) + } + }() + + wg.Add(1) + go func() { + defer wg.Done() + poller.Run(ctx) + }() + + // Recompute the 24h / 7d / 30d delta gauges every minute and prune + // snapshots older than 31 days. Separate from the poller so the + // gauges stay fresh even when a poll is in flight. + wg.Add(1) + go func() { + defer wg.Done() + ticker := time.NewTicker(60 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + now := time.Now() + t24, s24 := window.Delta(now, 24*time.Hour) + t7, s7 := window.Delta(now, 7*24*time.Hour) + t30, s30 := window.Delta(now, 30*24*time.Hour) + balanceDeltaUSD.WithLabelValues("total", "24h").Set(t24) + balanceDeltaUSD.WithLabelValues("stables", "24h").Set(s24) + balanceDeltaUSD.WithLabelValues("total", "7d").Set(t7) + balanceDeltaUSD.WithLabelValues("stables", "7d").Set(s7) + balanceDeltaUSD.WithLabelValues("total", "30d").Set(t30) + balanceDeltaUSD.WithLabelValues("stables", "30d").Set(s30) + window.Prune(now, 31*24*time.Hour) + windowSnapshots.Set(float64(window.Count())) + } + } + }() + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + <-sigChan + fmt.Println("\nShutting down...") + cancel() + wg.Wait() + fmt.Println("Stopped") +} diff --git a/harnesses/relay-revenue-monitor/cmd/monitor/metrics.go b/harnesses/relay-revenue-monitor/cmd/monitor/metrics.go new file mode 100644 index 00000000..6e53a055 --- /dev/null +++ b/harnesses/relay-revenue-monitor/cmd/monitor/metrics.go @@ -0,0 +1,96 @@ +package main + +import ( + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" + "net/http" +) + +// Metrics exposed by the harness. +// +// relay_solver_balance_usd{kind=total|stables} gauge — latest snapshot +// relay_solver_balance_delta_usd{kind, window} gauge — rolling delta +// relay_solver_assets_count gauge — total assets held +// relay_solver_poll_duration_seconds gauge — last poll latency +// relay_solver_polls_total counter — successful polls +// relay_solver_poll_errors_total counter — failed polls +// relay_solver_window_snapshots gauge — snapshots in memory +var ( + balanceUSD *prometheus.GaugeVec + balanceDeltaUSD *prometheus.GaugeVec + assetsCount prometheus.Gauge + pollDurationSec prometheus.Gauge + pollSuccess prometheus.Counter + pollErrors prometheus.Counter + windowSnapshots prometheus.Gauge +) + +func init() { + balanceUSD = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "relay_solver_balance_usd", + Help: "Latest snapshot of the Relay solver wallet balance in USD", + ConstLabels: prometheus.Labels{"provider": "relay"}, + }, + []string{"kind"}, + ) + prometheus.MustRegister(balanceUSD) + + balanceDeltaUSD = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "relay_solver_balance_delta_usd", + Help: "Balance change in USD over the named rolling window. Positive value = retained margin (floor on Relay revenue).", + ConstLabels: prometheus.Labels{"provider": "relay"}, + }, + []string{"kind", "window"}, + ) + prometheus.MustRegister(balanceDeltaUSD) + + assetsCount = prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "relay_solver_assets_count", + Help: "Total distinct assets held in the solver wallet across all chains Mobula indexes", + ConstLabels: prometheus.Labels{"provider": "relay"}, + }) + prometheus.MustRegister(assetsCount) + + pollDurationSec = prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "relay_solver_poll_duration_seconds", + Help: "Wall-clock time the last portfolio poll took, in seconds", + ConstLabels: prometheus.Labels{"provider": "relay"}, + }) + prometheus.MustRegister(pollDurationSec) + + pollSuccess = prometheus.NewCounter(prometheus.CounterOpts{ + Name: "relay_solver_polls_total", + Help: "Successful portfolio polls", + ConstLabels: prometheus.Labels{"provider": "relay"}, + }) + prometheus.MustRegister(pollSuccess) + + pollErrors = prometheus.NewCounter(prometheus.CounterOpts{ + Name: "relay_solver_poll_errors_total", + Help: "Failed portfolio polls", + ConstLabels: prometheus.Labels{"provider": "relay"}, + }) + prometheus.MustRegister(pollErrors) + + windowSnapshots = prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "relay_solver_window_snapshots", + Help: "Number of snapshots currently held in memory (diagnostic)", + ConstLabels: prometheus.Labels{"provider": "relay"}, + }) + prometheus.MustRegister(windowSnapshots) +} + +// StartMetricsServer exposes /metrics, /logs, /healthz on :2112 per the +// shared OCB harness convention. +func StartMetricsServer(addr string) error { + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.Handler()) + setupLogsEndpoint(mux) + 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/relay-revenue-monitor/cmd/monitor/poller.go b/harnesses/relay-revenue-monitor/cmd/monitor/poller.go new file mode 100644 index 00000000..b36e8e97 --- /dev/null +++ b/harnesses/relay-revenue-monitor/cmd/monitor/poller.go @@ -0,0 +1,138 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// Poller hits Mobula's wallet portfolio endpoint to fetch the Relay +// solver EOA's full balance in USD, across every chain Mobula indexes. +// Each successful poll appends a (timestamp, total_usd) snapshot to the +// rolling window so we can compute net balance delta over 24h/7d/30d. +// +// Balance delta = retained margin captured by the solver = the FLOOR on +// Relay's protocol revenue. Inflows alone would measure gross swap +// throughput (the wallet receives the full deposit then forwards almost +// all of it to the user on destination chain), so tracking the balance +// itself is the right proxy. +// +// Cadence: every PollIntervalSec (default 300s). The /wallet/portfolio +// endpoint takes 5-15s to resolve for a multi-chain wallet of this +// size, so we keep the HTTP timeout generous. +type Poller struct { + apiKey string + wallet string + window *Window + cfg *Config + client *http.Client +} + +func NewPoller(cfg *Config, window *Window) *Poller { + return &Poller{ + apiKey: cfg.MobulaAPIKey, + wallet: RelaySolver, + window: window, + cfg: cfg, + client: &http.Client{Timeout: 90 * time.Second}, + } +} + +// Run loops forever, polling the portfolio at PollIntervalSec cadence. +func (p *Poller) Run(ctx context.Context) { + // First poll fires immediately so the harness has a baseline snapshot + // instead of waiting one cadence period. + p.tick(ctx) + ticker := time.NewTicker(time.Duration(p.cfg.PollIntervalSec) * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + p.tick(ctx) + } + } +} + +func (p *Poller) tick(ctx context.Context) { + t0 := time.Now() + total, stable, assets, err := p.fetch(ctx) + if err != nil { + fmt.Printf("[poller] portfolio fetch error: %v\n", err) + pollErrors.Inc() + return + } + elapsed := time.Since(t0) + balanceUSD.WithLabelValues("total").Set(total) + balanceUSD.WithLabelValues("stables").Set(stable) + assetsCount.Set(float64(assets)) + pollDurationSec.Set(elapsed.Seconds()) + pollSuccess.Inc() + p.window.Add(time.Now(), total, stable) + fmt.Printf("[poller] balance=$%.0f stables=$%.0f assets=%d (took %.1fs)\n", + total, stable, assets, elapsed.Seconds()) +} + +// fetch hits the Mobula portfolio endpoint and extracts: +// - total_wallet_balance: cross-chain total in USD +// - stablecoin sub-balance (USDC + USDT + DAI + FRAX + USDE + FDUSD + ...) +// - assets count (diagnostic) +// +// Stablecoin sub-balance is the most defensible "margin captured" signal +// because it strips the price-volatility noise. If ETH price moves 5% +// the total balance moves a lot but the margin is unchanged; stable +// balance change is purely flow. +func (p *Poller) fetch(ctx context.Context) (float64, float64, int, error) { + endpoint := "https://api.mobula.io/api/1/wallet/portfolio?wallet=" + p.wallet + req, _ := http.NewRequestWithContext(ctx, "GET", endpoint, nil) + req.Header.Set("Authorization", p.apiKey) + resp, err := p.client.Do(req) + if err != nil { + return 0, 0, 0, err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 200)) + return 0, 0, 0, fmt.Errorf("http %d: %s", resp.StatusCode, string(body)) + } + body, _ := io.ReadAll(resp.Body) + var r struct { + Data struct { + TotalWalletBalance float64 `json:"total_wallet_balance"` + Assets []struct { + Asset struct { + Symbol string `json:"symbol"` + } `json:"asset"` + EstimatedBalance float64 `json:"estimated_balance"` + } `json:"assets"` + } `json:"data"` + } + if err := json.Unmarshal(body, &r); err != nil { + return 0, 0, 0, fmt.Errorf("parse: %v", err) + } + stable := 0.0 + for _, a := range r.Data.Assets { + sym := a.Asset.Symbol + if isStable(sym) { + stable += a.EstimatedBalance + } + } + return r.Data.TotalWalletBalance, stable, len(r.Data.Assets), nil +} + +// isStable returns true for symbols we treat as USD-pegged. List is +// intentionally narrow — only the major stablecoins so price-volatility +// noise stays low in the "stables" balance delta. +func isStable(symbol string) bool { + switch symbol { + case "USDC", "USDT", "DAI", "FRAX", "USDE", "FDUSD", "PYUSD", "TUSD", + "USDD", "GUSD", "LUSD", "MIM", "USDP", "BUSD", "EUROC", "EURC", + "USDS", "RLUSD": + return true + } + return false +} diff --git a/harnesses/relay-revenue-monitor/cmd/monitor/window.go b/harnesses/relay-revenue-monitor/cmd/monitor/window.go new file mode 100644 index 00000000..a72a5be2 --- /dev/null +++ b/harnesses/relay-revenue-monitor/cmd/monitor/window.go @@ -0,0 +1,83 @@ +package main + +import ( + "sync" + "time" +) + +// Window stores balance snapshots. Each snapshot is (timestamp, +// total_usd, stable_usd). Delta over window = balance[now] - balance[now +// - window]. The "now" sample is the most recent snapshot; the "older" +// sample is the closest one to the cutoff (last sample whose ts <= now +// - window). +// +// All in-memory. Restart wipes history; the 24h delta needs 24h of +// runtime to populate cleanly. The same trade-off every other OCB +// harness with histograms / counters takes. +type Window struct { + mu sync.Mutex + snapshots []snapshot +} + +type snapshot struct { + ts time.Time + total float64 + stable float64 +} + +func NewWindow() *Window { + return &Window{snapshots: make([]snapshot, 0, 1024)} +} + +// Add records a balance snapshot at time ts. +func (w *Window) Add(ts time.Time, total, stable float64) { + w.mu.Lock() + defer w.mu.Unlock() + w.snapshots = append(w.snapshots, snapshot{ts: ts, total: total, stable: stable}) +} + +// Delta returns (total_delta_usd, stable_delta_usd) over the trailing +// window ending at now. Returns (0, 0) if we don't yet have a snapshot +// older than (now - window). +func (w *Window) Delta(now time.Time, window time.Duration) (float64, float64) { + w.mu.Lock() + defer w.mu.Unlock() + if len(w.snapshots) < 2 { + return 0, 0 + } + latest := w.snapshots[len(w.snapshots)-1] + cutoff := now.Add(-window) + var older *snapshot + for i := range w.snapshots { + if w.snapshots[i].ts.After(cutoff) { + break + } + s := w.snapshots[i] + older = &s + } + if older == nil { + return 0, 0 + } + return latest.total - older.total, latest.stable - older.stable +} + +// Prune drops snapshots older than maxAge to bound memory growth. +func (w *Window) Prune(now time.Time, maxAge time.Duration) { + w.mu.Lock() + defer w.mu.Unlock() + cutoff := now.Add(-maxAge) + keep := w.snapshots[:0] + for _, s := range w.snapshots { + if s.ts.After(cutoff) { + keep = append(keep, s) + } + } + w.snapshots = keep +} + +// Count returns the number of snapshots currently stored (diagnostic). +func (w *Window) Count() int { + w.mu.Lock() + defer w.mu.Unlock() + return len(w.snapshots) +} diff --git a/harnesses/relay-revenue-monitor/go.mod b/harnesses/relay-revenue-monitor/go.mod new file mode 100644 index 00000000..956273b4 --- /dev/null +++ b/harnesses/relay-revenue-monitor/go.mod @@ -0,0 +1,17 @@ +module relay-revenue-monitor + +go 1.24.0 + +require github.com/prometheus/client_golang v1.20.5 + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/klauspost/compress v1.17.9 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // 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 + golang.org/x/sys v0.40.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/harnesses/relay-revenue-monitor/go.sum b/harnesses/relay-revenue-monitor/go.sum new file mode 100644 index 00000000..309d820f --- /dev/null +++ b/harnesses/relay-revenue-monitor/go.sum @@ -0,0 +1,24 @@ +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/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.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/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/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= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= +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= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/harnesses/relay-revenue-monitor/railway.toml b/harnesses/relay-revenue-monitor/railway.toml new file mode 100644 index 00000000..20d51ebf --- /dev/null +++ b/harnesses/relay-revenue-monitor/railway.toml @@ -0,0 +1,11 @@ +[build] +builder = "DOCKERFILE" +dockerfilePath = "Dockerfile" + +# No healthcheckPath: Railway probes the $PORT it injects, not :2112 +# where the OCB harness convention listens. The process is the liveness +# signal; restart-on-failure plus the ALL of the running poller logs are +# enough to know if something is wrong. +[deploy] +restartPolicyType = "ON_FAILURE" +restartPolicyMaxRetries = 10 diff --git a/harnesses/relay-revenue/.env.example b/harnesses/relay-revenue/.env.example new file mode 100644 index 00000000..d487ae74 --- /dev/null +++ b/harnesses/relay-revenue/.env.example @@ -0,0 +1,4 @@ +# Mobula API key (optional: preferred pricer, falls back to CoinGecko) +MOBULA_API_KEY= +# Token protecting GET /logs (loghub). Unset disables the endpoint. +LOGS_TOKEN= diff --git a/harnesses/relay-revenue/.gitignore b/harnesses/relay-revenue/.gitignore new file mode 100644 index 00000000..846f8fb3 --- /dev/null +++ b/harnesses/relay-revenue/.gitignore @@ -0,0 +1,4 @@ +/script +*.db +*.db-wal +*.db-shm diff --git a/harnesses/relay-revenue/Dockerfile b/harnesses/relay-revenue/Dockerfile new file mode 100644 index 00000000..b707bf82 --- /dev/null +++ b/harnesses/relay-revenue/Dockerfile @@ -0,0 +1,25 @@ +FROM golang:1.24-alpine AS builder + +WORKDIR /app +RUN apk add --no-cache git + +COPY go.mod go.sum* ./ +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 + +# Persistent SQLite store written to /app/data.db. Mount a Railway +# volume at /app to survive redeploys; otherwise the harness rebuilds +# from "now" (lazy backfill) on every container boot. +EXPOSE 2112 + +CMD ["/app/monitor"] diff --git a/harnesses/relay-revenue/cmd/script/api_client.go b/harnesses/relay-revenue/cmd/script/api_client.go new file mode 100644 index 00000000..4efefd32 --- /dev/null +++ b/harnesses/relay-revenue/cmd/script/api_client.go @@ -0,0 +1,117 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "time" +) + +// api_client.go — thin wrapper over GET https://api.relay.link/requests. +// +// Methodology notes (from audit): +// * `limit` is server-capped at 50 (HTTP 500 above). +// * `status=success` query param is BROKEN — must filter client side. +// * `startTimestamp` / `endTimestamp` (unix sec) DO work server-side +// and are the only reliable way to bound the iteration. +// * Continuation pagination is opaque; an empty string means "no +// more pages". +// * No auth; rate limits are very generous (30+ concurrent ok) but +// we keep it sequential to avoid hammering during backfill. + +const ( + relayBaseURL = "https://api.relay.link" + relayPageLimit = 50 + relayPageMax = 200 // hard cap per polling cycle — prevents runaway +) + +// RelayClient is a tiny stateful client over the Relay HTTP API. +type RelayClient struct { + baseURL string + http *http.Client +} + +func NewRelayClient() *RelayClient { + return &RelayClient{ + baseURL: relayBaseURL, + http: &http.Client{Timeout: 20 * time.Second}, + } +} + +// FetchPageOptions controls a single /requests call. +type FetchPageOptions struct { + Continuation string + StartTimestamp int64 // unix sec, 0 = unset + EndTimestamp int64 // unix sec, 0 = unset +} + +// FetchPage performs one GET /requests call. Increments Prom counters +// for both the request and (on non-2xx) the error path. +func (c *RelayClient) FetchPage(ctx context.Context, opts FetchPageOptions) (*RelayPage, error) { + q := url.Values{} + q.Set("limit", strconv.Itoa(relayPageLimit)) + if opts.Continuation != "" { + q.Set("continuation", opts.Continuation) + } + if opts.StartTimestamp > 0 { + q.Set("startTimestamp", strconv.FormatInt(opts.StartTimestamp, 10)) + } + if opts.EndTimestamp > 0 { + q.Set("endTimestamp", strconv.FormatInt(opts.EndTimestamp, 10)) + } + u := c.baseURL + "/requests?" + q.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, fmt.Errorf("build req: %w", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "mobula-openchainbench/relay-revenue (contact@mobula.io)") + + relayAPIRequestTotal.Inc() + resp, err := c.http.Do(req) + if err != nil { + relayAPIErrorsTotal.WithLabelValues("network").Inc() + return nil, fmt.Errorf("http get: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 16*1024*1024)) + if err != nil { + relayAPIErrorsTotal.WithLabelValues("read").Inc() + return nil, fmt.Errorf("read body: %w", err) + } + + if resp.StatusCode >= 400 { + relayAPIErrorsTotal.WithLabelValues(httpErrLabel(resp.StatusCode)).Inc() + preview := string(body) + if len(preview) > 200 { + preview = preview[:200] + } + return nil, fmt.Errorf("relay http %d: %s", resp.StatusCode, preview) + } + + var page RelayPage + if err := json.Unmarshal(body, &page); err != nil { + relayAPIErrorsTotal.WithLabelValues("decode").Inc() + return nil, fmt.Errorf("decode page: %w", err) + } + return &page, nil +} + +func httpErrLabel(code int) string { + switch { + case code == 429: + return "rate_limited" + case code >= 500: + return "server" + case code >= 400: + return "client" + default: + return "other" + } +} diff --git a/harnesses/relay-revenue/cmd/script/chains.go b/harnesses/relay-revenue/cmd/script/chains.go new file mode 100644 index 00000000..c8de4caf --- /dev/null +++ b/harnesses/relay-revenue/cmd/script/chains.go @@ -0,0 +1,104 @@ +package main + +// chains.go — Relay chainId → native pricing token mapping. +// +// We need to convert raw gas (paid in the chain's native asset) and +// native-denominated swap legs into USD. Relay returns the chain by its +// numeric `chainId`. Adding a row enables BOTH gas pricing and native +// asset USD valuation; ERC-20 lookup is configured separately in +// pricer.go (chainSlugs map). +// +// `nativeSymbol` is the symbol Relay sends for this chain's gas asset. +// `nativeCoingeckoID` is the canonical CoinGecko id (preferred over +// symbol when present — symbols collide, ids don't). + +// ChainSpec carries the per-chain native-token pricing info plus the +// number of decimals on the native unit. +// +// MobulaNativeAsset is what to pass as ?asset= to Mobula's /market/data +// for this chain's gas token. Mobula's name vocabulary diverges from +// CoinGecko's on several majors (BNB ≠ binancecoin, POL ≠ matic-network, +// AVAX ≠ avalanche-2) — empirically probed against the live API. Empty +// string means "use NativeCoingeckoID" (works for ETH/SOL/BTC). +type ChainSpec struct { + ChainID int64 + Name string + NativeSymbol string + NativeCoingeckoID string + MobulaNativeAsset string + NativeDecimals int +} + +// relayChains is keyed by chainId for O(1) lookup. Expanded from the +// initial 18-chain map to cover the full Relay catalog per their +// `/chains` docs as of May 2026. Unknown chains return ok=false from +// chainSpec, which flips the affected swap to priced=false (correct). +var relayChains = map[int64]ChainSpec{ + // ─── EVM L1 & majors ─── + 1: {ChainID: 1, Name: "ethereum", NativeSymbol: "ETH", NativeCoingeckoID: "ethereum", NativeDecimals: 18}, + 56: {ChainID: 56, Name: "bsc", NativeSymbol: "BNB", NativeCoingeckoID: "binancecoin", MobulaNativeAsset: "BNB", NativeDecimals: 18}, + 137: {ChainID: 137, Name: "polygon", NativeSymbol: "POL", NativeCoingeckoID: "matic-network", MobulaNativeAsset: "POL (ex-MATIC)", NativeDecimals: 18}, + 43114: {ChainID: 43114, Name: "avalanche", NativeSymbol: "AVAX", NativeCoingeckoID: "avalanche-2", MobulaNativeAsset: "avalanche", NativeDecimals: 18}, + 250: {ChainID: 250, Name: "fantom", NativeSymbol: "FTM", NativeCoingeckoID: "fantom", NativeDecimals: 18}, + 2020: {ChainID: 2020, Name: "ronin", NativeSymbol: "RON", NativeCoingeckoID: "ronin", NativeDecimals: 18}, + 1329: {ChainID: 1329, Name: "sei", NativeSymbol: "SEI", NativeCoingeckoID: "sei-network", NativeDecimals: 18}, + 5000: {ChainID: 5000, Name: "mantle", NativeSymbol: "MNT", NativeCoingeckoID: "mantle", NativeDecimals: 18}, + 80094: {ChainID: 80094, Name: "berachain", NativeSymbol: "BERA", NativeCoingeckoID: "berachain-bera", NativeDecimals: 18}, + 33139: {ChainID: 33139, Name: "apechain", NativeSymbol: "APE", NativeCoingeckoID: "apecoin", NativeDecimals: 18}, + 1088: {ChainID: 1088, Name: "metis", NativeSymbol: "METIS", NativeCoingeckoID: "metis-token", NativeDecimals: 18}, + + // ─── ETH L2s (all native = ETH) ─── + 10: {ChainID: 10, Name: "optimism", NativeSymbol: "ETH", NativeCoingeckoID: "ethereum", NativeDecimals: 18}, + 130: {ChainID: 130, Name: "unichain", NativeSymbol: "ETH", NativeCoingeckoID: "ethereum", NativeDecimals: 18}, + 288: {ChainID: 288, Name: "boba", NativeSymbol: "ETH", NativeCoingeckoID: "ethereum", NativeDecimals: 18}, + 324: {ChainID: 324, Name: "zksync", NativeSymbol: "ETH", NativeCoingeckoID: "ethereum", NativeDecimals: 18}, + 480: {ChainID: 480, Name: "worldchain", NativeSymbol: "ETH", NativeCoingeckoID: "ethereum", NativeDecimals: 18}, + 1101: {ChainID: 1101, Name: "polygon-zkevm", NativeSymbol: "ETH", NativeCoingeckoID: "ethereum", NativeDecimals: 18}, + 1135: {ChainID: 1135, Name: "lisk", NativeSymbol: "ETH", NativeCoingeckoID: "ethereum", NativeDecimals: 18}, + 1868: {ChainID: 1868, Name: "soneium", NativeSymbol: "ETH", NativeCoingeckoID: "ethereum", NativeDecimals: 18}, + 7777777: {ChainID: 7777777, Name: "zora", NativeSymbol: "ETH", NativeCoingeckoID: "ethereum", NativeDecimals: 18}, + 8333: {ChainID: 8333, Name: "b3", NativeSymbol: "ETH", NativeCoingeckoID: "ethereum", NativeDecimals: 18}, + 8453: {ChainID: 8453, Name: "base", NativeSymbol: "ETH", NativeCoingeckoID: "ethereum", NativeDecimals: 18}, + 34443: {ChainID: 34443, Name: "mode", NativeSymbol: "ETH", NativeCoingeckoID: "ethereum", NativeDecimals: 18}, + 42161: {ChainID: 42161, Name: "arbitrum", NativeSymbol: "ETH", NativeCoingeckoID: "ethereum", NativeDecimals: 18}, + 57073: {ChainID: 57073, Name: "ink", NativeSymbol: "ETH", NativeCoingeckoID: "ethereum", NativeDecimals: 18}, + 59144: {ChainID: 59144, Name: "linea", NativeSymbol: "ETH", NativeCoingeckoID: "ethereum", NativeDecimals: 18}, + 60808: {ChainID: 60808, Name: "bob", NativeSymbol: "ETH", NativeCoingeckoID: "ethereum", NativeDecimals: 18}, + 81457: {ChainID: 81457, Name: "blast", NativeSymbol: "ETH", NativeCoingeckoID: "ethereum", NativeDecimals: 18}, + 534352: {ChainID: 534352, Name: "scroll", NativeSymbol: "ETH", NativeCoingeckoID: "ethereum", NativeDecimals: 18}, + + // ─── Sovereign chains with their own gas token ─── + 666666666: {ChainID: 666666666, Name: "degen", NativeSymbol: "DEGEN", NativeCoingeckoID: "degen-base", NativeDecimals: 18}, + 999: {ChainID: 999, Name: "hyperevm", NativeSymbol: "HYPE", NativeCoingeckoID: "hyperliquid", NativeDecimals: 18}, + 2741: {ChainID: 2741, Name: "abstract", NativeSymbol: "ETH", NativeCoingeckoID: "ethereum", NativeDecimals: 18}, + 100: {ChainID: 100, Name: "gnosis", NativeSymbol: "XDAI", NativeCoingeckoID: "xdai", MobulaNativeAsset: "xDAI", NativeDecimals: 18}, + 169: {ChainID: 169, Name: "manta", NativeSymbol: "ETH", NativeCoingeckoID: "ethereum", NativeDecimals: 18}, + 98866: {ChainID: 98866, Name: "plume", NativeSymbol: "PLUME", NativeCoingeckoID: "plume", NativeDecimals: 18}, + // TRON via Relay's off-EVM convention (728126428 audited). + 728126428: {ChainID: 728126428, Name: "tron", NativeSymbol: "TRX", NativeCoingeckoID: "tron", NativeDecimals: 6}, + // chainid.network-registered chains observed dropping in audit: + 25: {ChainID: 25, Name: "cronos", NativeSymbol: "CRO", NativeCoingeckoID: "crypto-com-chain", MobulaNativeAsset: "cronos", NativeDecimals: 18}, + 143: {ChainID: 143, Name: "monad", NativeSymbol: "MON", NativeCoingeckoID: "monad", NativeDecimals: 18}, + 360: {ChainID: 360, Name: "shape", NativeSymbol: "ETH", NativeCoingeckoID: "ethereum", NativeDecimals: 18}, + 48900: {ChainID: 48900, Name: "zircuit", NativeSymbol: "ETH", NativeCoingeckoID: "ethereum", NativeDecimals: 18}, + 747474: {ChainID: 747474, Name: "katana", NativeSymbol: "ETH", NativeCoingeckoID: "ethereum", NativeDecimals: 18}, + 685689: {ChainID: 685689, Name: "gensyn", NativeSymbol: "ETH", NativeCoingeckoID: "ethereum", NativeDecimals: 18}, + // Mythos gaming chain (FIFA Rivals, NFL Rivals); not in chainid.network + // but confirmed via direct Relay swap inspection. Mobula slug "mythos". + 42018: {ChainID: 42018, Name: "mythos", NativeSymbol: "MYTH", NativeCoingeckoID: "mythos", NativeDecimals: 18}, + + // ─── Non-EVM chains Relay routes through ─── + // Solana uses Relay's off-EVM chainId 792703809; native is SOL, 9 decimals. + 792703809: {ChainID: 792703809, Name: "solana", NativeSymbol: "SOL", NativeCoingeckoID: "solana", NativeDecimals: 9}, + // Bitcoin via Relay's BTC bridge. Relay uses 8253038 as the Bitcoin + // chainId per their public requests API (audited May 2026); 1337 in + // an earlier draft was a copy-paste from Ethereum local-dev convention. + 8253038: {ChainID: 8253038, Name: "bitcoin", NativeSymbol: "BTC", NativeCoingeckoID: "bitcoin", NativeDecimals: 8}, + // Eclipse (Solana L2 on Ethereum); native is ETH, SVM-style accounts. + 100024: {ChainID: 100024, Name: "eclipse", NativeSymbol: "ETH", NativeCoingeckoID: "ethereum", NativeDecimals: 9}, +} + +func chainSpec(id int64) (ChainSpec, bool) { + cs, ok := relayChains[id] + return cs, ok +} diff --git a/harnesses/relay-revenue/cmd/script/compute.go b/harnesses/relay-revenue/cmd/script/compute.go new file mode 100644 index 00000000..bbc07062 --- /dev/null +++ b/harnesses/relay-revenue/cmd/script/compute.go @@ -0,0 +1,372 @@ +package main + +import ( + "context" + "fmt" + "math" + "math/big" + "strconv" + "strings" + "time" +) + +// Sanity bounds: any USD figure outside these is a pricing artifact, +// not a legitimate swap economic. Calibrated against Relay's real +// population — largest observed leg in the May 2026 audit was ~$10M, +// largest legitimate margin under $1k. +// +// Tighter `maxMarginUSD` after the June 2026 audit: a small number of +// swaps with margin in the -$2M to -$5M range were polluting the 7d / +// 30d aggregates with persistent negative totals. Those margins are +// mathematically impossible for an honest routing protocol; they +// reflect a stale price on one leg vs Relay's `amountUsd` hint on the +// other. We now drop anything outside ±$10k as implausible and emit +// the count on `relay_swaps_dropped_implausible{label=margin}`. +// +// A separate `minMarginUSD` floor catches the negative tail +// specifically: even a $5k legitimate loss on a single swap is so rare +// for a maker-style router that treating it as data quality is the +// right default. Genuine outliers can be re-included by relaxing this +// without redeploying the harness once the upstream pricing is fixed. +const ( + maxLegUSD = 1e9 // $1B per swap leg + maxMarginUSD = 1e4 // $10k absolute margin per swap + minMarginUSD = -5e3 // -$5k floor: more negative = pricing artifact +) + +// isImplausibleUSD returns true for NaN/Inf or values outside the +// per-leg sanity envelope. Centralised so every USD-producing path +// uses the same guard. +func isImplausibleUSD(v float64) bool { + return math.IsNaN(v) || math.IsInf(v, 0) || math.Abs(v) > maxLegUSD +} + +// compute.go — implements the implied-margin formula: +// +// implied_margin_usd = usd_in - usd_out - sum(gas_usd) - sum(appFees_usd) +// +// where: +// * usd_in/out use data.metadata.currencyIn/out (amount in raw +// smallest unit; we apply decimals + USD price). +// * gas = sum over data.inTxs[].fee + data.outTxs[].fee, each priced +// in the native token of the tx's chainId. +// * appFees = sum over data.appFees[].amount, priced in +// data.appFees[].currency (typically USDC). +// +// Pricing strategy per currency leg, in order: +// 1. Relay's own `amountUsd` hint when present (computed at swap time) +// 2. Stable peg by symbol (USDC, USDT, DAI, FRAX, USDe, …) +// 3. Native asset of a known chain via CG slug from chainSpec +// 4. ERC-20 / SPL via PriceTokenUSD(chainId, address) +// 5. Bare symbol fallback (rarely helps; kept for tests) +// +// Returns a Swap with Priced=true ONLY when every required leg priced +// successfully. A single missing price flips Priced=false; we still +// store the swap so downstream consumers see "we saw it but couldn't +// value it" instead of silently losing volume. + +// ComputeMargin runs the formula on one Relay request. +func ComputeMargin(ctx context.Context, req RelayRequest, pricer Pricer) (Swap, error) { + createdAt, err := parseRelayTimestamp(req.CreatedAt) + if err != nil { + return Swap{}, fmt.Errorf("parse createdAt: %w", err) + } + + s := Swap{ + ID: req.ID, + CreatedAt: createdAt, + Status: req.Status, + ChainIn: req.Data.Metadata.CurrencyIn.Currency.ChainID, + ChainOut: req.Data.Metadata.CurrencyOut.Currency.ChainID, + Priced: true, // optimistic, flipped to false on any miss + } + + usdIn, ok := amountToUSD(ctx, pricer, req.Data.Metadata.CurrencyIn) + if !ok { + s.Priced = false + } + s.VolumeUSD = usdIn + + usdOut, ok := amountToUSD(ctx, pricer, req.Data.Metadata.CurrencyOut) + if !ok { + s.Priced = false + } + + gasUSD, gasOK := sumGasUSD(ctx, pricer, req.Data.InTxs, req.Data.OutTxs) + if !gasOK { + s.Priced = false + } + s.GasUSD = gasUSD + + feesUSD, feesOK := sumAppFeesUSD(ctx, pricer, req.Data.AppFees) + if !feesOK { + s.Priced = false + } + s.AppFeesUSD = feesUSD + + if s.Priced { + s.MarginUSD = usdIn - usdOut - gasUSD - feesUSD + // Two guards, both treat the swap as a pricing artifact rather + // than data: + // 1. |margin| > $10k — almost no legitimate Relay swap has a + // margin this big; values that large indicate one leg was + // priced from a stale or wrong source. + // 2. margin < -$5k — even a moderate negative margin is + // mathematically impossible for an honest router (it would + // mean Relay paid the user MORE than they sent). When this + // fires, it's the stale-price-on-one-leg-vs-Relay-hint-on- + // the-other case. Dropping these prevents the 7d/30d + // aggregates from going persistently negative. + // In both cases we flip Priced=false (so the swap is excluded + // from volume + margin sums but still counted as unpriced) and + // emit a Railway stdout line so the offending swap is debuggable. + if math.Abs(s.MarginUSD) > maxMarginUSD || s.MarginUSD < minMarginUSD { + fmt.Printf("[implausible] swap=%s margin_usd=%.3e usd_in=%.3e usd_out=%.3e gas=%.3e fees=%.3e chains=%d→%d\n", + s.ID, s.MarginUSD, usdIn, usdOut, gasUSD, feesUSD, s.ChainIn, s.ChainOut) + relaySwapsDroppedImplausible.WithLabelValues("margin").Inc() + s.Priced = false + s.MarginUSD = 0 + } + } + return s, nil +} + +// amountToUSD converts a RelayAmount (raw smallest unit + currency +// metadata) to USD. +// +// Short-circuit: when Relay populated `amountUsd` for the leg, we use +// that directly. That's Relay's own price at the swap timestamp, which +// is the *correct* number for an after-the-fact USD valuation — better +// than recomputing with our spot price minutes/hours later (especially +// during backfill). Falls back to our own pricer when the hint is +// absent or unparseable. +func amountToUSD(ctx context.Context, pricer Pricer, amt RelayAmount) (float64, bool) { + if amt.Amount == "" || amt.Currency.Symbol == "" { + return 0, true // empty leg is valid (zero), not a pricing failure + } + if v, ok := parseRelayUSDHint(amt.AmountUSD); ok { + return v, true + } + priceUSD, ok := resolvePrice(ctx, pricer, amt.Currency) + if !ok { + return 0, false + } + scaled, ok := scaleAmount(amt.Amount, amt.Currency.Decimals) + if !ok { + return 0, false + } + val := scaled * priceUSD + // Catches the (decimals=0 in Relay response) × (corrupted oracle + // price) blow-ups that produced -1.5e+61 in prod. Mark unpriced + // instead of writing a Mt-Everest USD into the DB. + if isImplausibleUSD(val) { + relaySwapsDroppedImplausible.WithLabelValues("amount").Inc() + return 0, false + } + return val, true +} + +// parseRelayUSDHint reads RelayAmount.AmountUSD. Treat anything ≤ 0 or +// unparseable as "no hint" so the caller falls through to our pricer. +// Implausibly large hints (Relay-side encoding glitch) are also +// rejected — the caller's pricer fallback will either compute a sane +// value or flip the swap to unpriced. +func parseRelayUSDHint(raw string) (float64, bool) { + s := strings.TrimSpace(raw) + if s == "" { + return 0, false + } + v, err := strconv.ParseFloat(s, 64) + if err != nil || v <= 0 { + return 0, false + } + if isImplausibleUSD(v) { + relaySwapsDroppedImplausible.WithLabelValues("hint").Inc() + return 0, false + } + return v, true +} + +// sumGasUSD walks in+out txs and converts each .fee to USD using the +// native asset of the tx's chainId. Returns ok=false if any chain is +// unknown (we can't price it) — that lets the caller mark the swap as +// unpriced rather than silently undercounting cost. +func sumGasUSD(ctx context.Context, pricer Pricer, inTxs, outTxs []RelayTx) (float64, bool) { + total := 0.0 + ok := true + for _, tx := range append(append([]RelayTx{}, inTxs...), outTxs...) { + if tx.Fee == "" || tx.Fee == "0" { + continue + } + cs, found := chainSpec(tx.ChainID) + if !found { + ok = false + continue + } + priceUSD, priceOK := priceNative(ctx, pricer, cs) + if !priceOK { + ok = false + continue + } + scaled, scaledOK := scaleAmount(tx.Fee, cs.NativeDecimals) + if !scaledOK { + ok = false + continue + } + val := scaled * priceUSD + if isImplausibleUSD(val) { + relaySwapsDroppedImplausible.WithLabelValues("gas").Inc() + ok = false + continue + } + total += val + } + return total, ok +} + +// priceNative resolves a chain's gas-token USD price. Tries Mobula- +// specific asset names first (Mobula's vocabulary diverges from +// CoinGecko's on BNB/POL/AVAX), then falls through to the canonical +// CoinGecko id, then the bare symbol. Either pricer in the chain wins +// at the first hit. +func priceNative(ctx context.Context, pricer Pricer, cs ChainSpec) (float64, bool) { + if cs.MobulaNativeAsset != "" { + if v, ok := pricer.PriceUSD(ctx, cs.MobulaNativeAsset, cs.NativeSymbol); ok { + return v, true + } + } + if v, ok := pricer.PriceUSD(ctx, cs.NativeCoingeckoID, cs.NativeSymbol); ok { + return v, true + } + return 0, false +} + +// sumAppFeesUSD sums RelayAppFee[].amount priced in each fee's +// declared currency. Empty appFees → 0, ok=true (very common, 29%). +// Degenerate entries (Amount > 0 but the Currency block is missing +// chainId / address / symbol) appear in the swap stream; we treat them +// as Relay-data noise and skip the contribution rather than flip the +// whole swap to unpriced. +func sumAppFeesUSD(ctx context.Context, pricer Pricer, fees []RelayAppFee) (float64, bool) { + total := 0.0 + ok := true + for _, f := range fees { + if f.Amount == "" || f.Amount == "0" { + continue + } + if isCurrencyEmpty(f.Currency) { + continue // Relay quirk: skip without flipping ok=false + } + priceUSD, priceOK := resolvePrice(ctx, pricer, f.Currency) + if !priceOK { + ok = false + continue + } + scaled, scaledOK := scaleAmount(f.Amount, f.Currency.Decimals) + if !scaledOK { + ok = false + continue + } + val := scaled * priceUSD + if isImplausibleUSD(val) { + relaySwapsDroppedImplausible.WithLabelValues("appfee").Inc() + ok = false + continue + } + total += val + } + return total, ok +} + +// isCurrencyEmpty detects the degenerate currency block sometimes +// emitted on appFees (no chainId, no address, no symbol). Without this +// guard such entries propagate to ok=false and tank coverage. +func isCurrencyEmpty(c RelayCurrency) bool { + return c.ChainID == 0 && c.Address == "" && c.Symbol == "" +} + +// resolvePrice maps a RelayCurrency to USD. Strategy: +// 1. stable peg by symbol → $1 +// 2. native asset of a known chain → CG id from chainSpec +// 3. ERC-20 / SPL → PriceTokenUSD via (chainId, address) +// 4. bare symbol — last-ditch, rarely helps, kept for test fixtures +func resolvePrice(ctx context.Context, pricer Pricer, c RelayCurrency) (float64, bool) { + if c.Symbol != "" && stablePegs[strings.ToUpper(c.Symbol)] { + return 1.0, true + } + if isNativeCurrency(c) { + cs, found := chainSpec(c.ChainID) + if !found { + return 0, false + } + return priceNative(ctx, pricer, cs) + } + // ERC-20 / SPL: contract-address lookup via Mobula (or CG fallback). + if c.Address != "" { + if v, ok := pricer.PriceTokenUSD(ctx, c.ChainID, c.Address, c.Symbol); ok { + return v, true + } + } + // Last-ditch: pricer's symbol-only lookup. Almost never resolves + // real Relay swaps; kept so tests can author by symbol. + if c.Symbol != "" { + if v, ok := pricer.PriceUSD(ctx, "", c.Symbol); ok { + return v, true + } + } + return 0, false +} + +// isNativeCurrency = "the chain's gas token". Relay marks these with +// the zero-address or an empty address; symbols also match the native +// of that chain. We're conservative — require the symbol to match. +func isNativeCurrency(c RelayCurrency) bool { + cs, ok := chainSpec(c.ChainID) + if !ok { + return false + } + addrEmpty := c.Address == "" || + c.Address == "0x0000000000000000000000000000000000000000" || + strings.EqualFold(c.Address, "11111111111111111111111111111111") // Solana System Program + return addrEmpty || strings.EqualFold(c.Symbol, cs.NativeSymbol) +} + +// scaleAmount divides raw `amount` by 10^decimals using big.Float so +// 256-bit wei values don't lose precision before the float64 cast. +// Returns ok=false on parse error. +func scaleAmount(raw string, decimals int) (float64, bool) { + if decimals < 0 { + return 0, false + } + bi := new(big.Int) + if _, ok := bi.SetString(strings.TrimSpace(raw), 10); !ok { + return 0, false + } + bf := new(big.Float).SetInt(bi) + div := new(big.Float).SetFloat64(pow10(decimals)) + bf.Quo(bf, div) + v, _ := bf.Float64() + return v, true +} + +func pow10(n int) float64 { + v := 1.0 + for i := 0; i < n; i++ { + v *= 10.0 + } + return v +} + +// parseRelayTimestamp accepts RFC3339 with or without sub-second +// precision (Relay returns both). +func parseRelayTimestamp(s string) (int64, error) { + if s == "" { + return time.Now().Unix(), nil + } + for _, layout := range []string{time.RFC3339Nano, time.RFC3339} { + if t, err := time.Parse(layout, s); err == nil { + return t.Unix(), nil + } + } + return 0, fmt.Errorf("unrecognised timestamp %q", s) +} diff --git a/harnesses/relay-revenue/cmd/script/compute_test.go b/harnesses/relay-revenue/cmd/script/compute_test.go new file mode 100644 index 00000000..2d318bd7 --- /dev/null +++ b/harnesses/relay-revenue/cmd/script/compute_test.go @@ -0,0 +1,387 @@ +package main + +import ( + "context" + "math" + "testing" +) + +// compute_test.go — offline coverage of ComputeMargin. No network +// calls; we drive everything through staticPricer fixtures. +// +// Cases: +// happy path — known ETH/SOL swap with known prices → known margin +// empty appFees — 0 added to fees, ok=true +// multi-currency appFees — different stable + native legs sum +// missing prices — unknown asset → priced=false, no panic +// multi-tx gas — two inTxs + two outTxs all sum +// bridged stables — USDC.E on Polygon prices as $1 (key drop fix) +// ERC-20 lookup — non-native, non-stable token resolved via PriceTokenUSD +// Relay USD hint — amountUsd populated short-circuits the pricer +// +// Pricer fixture: ETH=$3,000 SOL=$150 BTC=$70,000 BNB=$600 +// Stables ($1) handled by the stablePegs symbol short-circuit. + +const float64Eps = 0.0001 + +func newTestPricer() *staticPricer { + return newStaticPricer(map[string]float64{ + "ethereum": 3_000, + "solana": 150, + "bitcoin": 70_000, + "binancecoin": 600, + "ronin": 2, + "matic-network": 0.5, + }) +} + +func approxEq(t *testing.T, name string, got, want float64) { + t.Helper() + if math.Abs(got-want) > float64Eps*math.Max(1, math.Abs(want)) { + t.Errorf("%s: got %.6f want %.6f", name, got, want) + } +} + +func TestComputeMargin_HappyPath(t *testing.T) { + // User swaps 1 ETH on Ethereum → 19.5 SOL on Solana. Relay pays + // 0.01 ETH gas in, 0.001 SOL gas out, no app fees. + // usd_in = 1 * 3000 = 3000 + // usd_out = 19.5 * 150 = 2925 + // gas_in = 0.01 * 3000 = 30 + // gas_out = 0.001 * 150 = 0.15 + // margin = 3000 - 2925 - 30.15 - 0 = 44.85 + req := RelayRequest{ + ID: "swap-1", + Status: "success", + CreatedAt: "2026-05-23T12:00:00Z", + Data: RelayReqData{ + Metadata: RelayMetadata{ + CurrencyIn: RelayAmount{Currency: RelayCurrency{ChainID: 1, Symbol: "ETH", Address: "", Decimals: 18}, Amount: "1000000000000000000"}, + CurrencyOut: RelayAmount{Currency: RelayCurrency{ChainID: 792703809, Symbol: "SOL", Address: "", Decimals: 9}, Amount: "19500000000"}, + }, + InTxs: []RelayTx{{Hash: "0xin", ChainID: 1, Fee: "10000000000000000"}}, + OutTxs: []RelayTx{{Hash: "solout", ChainID: 792703809, Fee: "1000000"}}, + }, + } + sw, err := ComputeMargin(context.Background(), req, newTestPricer()) + if err != nil { + t.Fatalf("ComputeMargin: %v", err) + } + if !sw.Priced { + t.Fatalf("expected priced=true, got false") + } + approxEq(t, "volume", sw.VolumeUSD, 3000) + approxEq(t, "gas", sw.GasUSD, 30.15) + approxEq(t, "appfees", sw.AppFeesUSD, 0) + approxEq(t, "margin", sw.MarginUSD, 44.85) +} + +func TestComputeMargin_EmptyAppFees(t *testing.T) { + // Same shape as happy path but with explicit empty AppFees slice. + // Empty appFees is observed in 29% of real swaps — must not flip + // priced to false. + req := RelayRequest{ + ID: "swap-2", + Status: "success", + CreatedAt: "2026-05-23T12:00:00Z", + Data: RelayReqData{ + Metadata: RelayMetadata{ + CurrencyIn: RelayAmount{Currency: RelayCurrency{ChainID: 8453, Symbol: "ETH", Decimals: 18}, Amount: "500000000000000000"}, + CurrencyOut: RelayAmount{Currency: RelayCurrency{ChainID: 42161, Symbol: "ETH", Decimals: 18}, Amount: "498000000000000000"}, + }, + InTxs: []RelayTx{{Hash: "0xa", ChainID: 8453, Fee: "1000000000000000"}}, + OutTxs: []RelayTx{{Hash: "0xb", ChainID: 42161, Fee: "500000000000000"}}, + AppFees: nil, + }, + } + sw, err := ComputeMargin(context.Background(), req, newTestPricer()) + if err != nil { + t.Fatalf("ComputeMargin: %v", err) + } + if !sw.Priced { + t.Fatalf("expected priced=true, got false") + } + approxEq(t, "volume", sw.VolumeUSD, 1500) + approxEq(t, "appfees", sw.AppFeesUSD, 0) +} + +func TestComputeMargin_MultiCurrencyAppFees(t *testing.T) { + // Two appFees: USDC ($1 each) + ETH (native at $3000). Both must + // price correctly and add to the total. + req := RelayRequest{ + ID: "swap-3", + Status: "success", + CreatedAt: "2026-05-23T12:00:00Z", + Data: RelayReqData{ + Metadata: RelayMetadata{ + CurrencyIn: RelayAmount{Currency: RelayCurrency{ChainID: 1, Symbol: "ETH", Decimals: 18}, Amount: "1000000000000000000"}, + CurrencyOut: RelayAmount{Currency: RelayCurrency{ChainID: 8453, Symbol: "ETH", Decimals: 18}, Amount: "990000000000000000"}, + }, + InTxs: []RelayTx{{Hash: "0xa", ChainID: 1, Fee: "0"}}, + OutTxs: []RelayTx{{Hash: "0xb", ChainID: 8453, Fee: "0"}}, + AppFees: []RelayAppFee{ + {Recipient: "0xrec1", Amount: "5000000", Currency: RelayCurrency{ChainID: 8453, Symbol: "USDC", Decimals: 6}}, + {Recipient: "0xrec2", Amount: "1000000000000000", Currency: RelayCurrency{ChainID: 1, Symbol: "ETH", Decimals: 18}}, + }, + }, + } + sw, err := ComputeMargin(context.Background(), req, newTestPricer()) + if err != nil { + t.Fatalf("ComputeMargin: %v", err) + } + if !sw.Priced { + t.Fatalf("expected priced=true, got false") + } + // 5 USDC + 0.001 ETH ($3) = $8 + approxEq(t, "appfees", sw.AppFeesUSD, 8) +} + +func TestComputeMargin_MissingPriceMarksUnpriced(t *testing.T) { + // Output is on a chain we don't know about. ComputeMargin should + // not panic; it should return priced=false but still produce a row. + req := RelayRequest{ + ID: "swap-4", + Status: "success", + CreatedAt: "2026-05-23T12:00:00Z", + Data: RelayReqData{ + Metadata: RelayMetadata{ + CurrencyIn: RelayAmount{Currency: RelayCurrency{ChainID: 1, Symbol: "ETH", Decimals: 18}, Amount: "1000000000000000000"}, + CurrencyOut: RelayAmount{Currency: RelayCurrency{ChainID: 99999999, Symbol: "ZZZ", Decimals: 18}, Amount: "1000000000000000000"}, + }, + InTxs: []RelayTx{{Hash: "0xa", ChainID: 1, Fee: "0"}}, + OutTxs: []RelayTx{{Hash: "0xb", ChainID: 99999999, Fee: "1000000000000000"}}, + }, + } + sw, err := ComputeMargin(context.Background(), req, newTestPricer()) + if err != nil { + t.Fatalf("ComputeMargin returned error on unknown chain: %v", err) + } + if sw.Priced { + t.Fatalf("expected priced=false for unknown asset, got true") + } + if sw.MarginUSD != 0 { + t.Errorf("unpriced swap should have margin=0, got %.6f", sw.MarginUSD) + } +} + +func TestComputeMargin_MultiTxGas(t *testing.T) { + // Two inTxs + two outTxs, all priced, must sum. + req := RelayRequest{ + ID: "swap-5", + Status: "success", + CreatedAt: "2026-05-23T12:00:00Z", + Data: RelayReqData{ + Metadata: RelayMetadata{ + CurrencyIn: RelayAmount{Currency: RelayCurrency{ChainID: 1, Symbol: "ETH", Decimals: 18}, Amount: "1000000000000000000"}, + CurrencyOut: RelayAmount{Currency: RelayCurrency{ChainID: 792703809, Symbol: "SOL", Decimals: 9}, Amount: "19000000000"}, + }, + InTxs: []RelayTx{ + {ChainID: 1, Fee: "1000000000000000"}, + {ChainID: 1, Fee: "2000000000000000"}, + }, + OutTxs: []RelayTx{ + {ChainID: 792703809, Fee: "10000000"}, + {ChainID: 792703809, Fee: "20000000"}, + }, + }, + } + sw, err := ComputeMargin(context.Background(), req, newTestPricer()) + if err != nil { + t.Fatalf("ComputeMargin: %v", err) + } + if !sw.Priced { + t.Fatalf("expected priced=true, got false") + } + approxEq(t, "gas", sw.GasUSD, 13.5) +} + +// TestComputeMargin_USDCBridgedVariant covers the highest-impact drop +// cause discovered in the audit: USDC.E (Polygon bridged USDC) accounts +// for ~27 % of all dropped swaps because the original stablePegs list +// only matched exact "USDC". A swap with USDC.E on one leg must now +// price cleanly at $1. +func TestComputeMargin_USDCBridgedVariant(t *testing.T) { + req := RelayRequest{ + ID: "swap-usdce", + Status: "success", + CreatedAt: "2026-05-23T12:00:00Z", + Data: RelayReqData{ + Metadata: RelayMetadata{ + CurrencyIn: RelayAmount{ + Currency: RelayCurrency{ChainID: 137, Symbol: "USDC.E", Address: "0x2791bca1f2de4661ed88a30c99a7a9449aa84174", Decimals: 6}, + Amount: "100000000", // 100 USDC.E + }, + CurrencyOut: RelayAmount{ + Currency: RelayCurrency{ChainID: 8453, Symbol: "USDC", Address: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", Decimals: 6}, + Amount: "99500000", // 99.5 USDC + }, + }, + InTxs: []RelayTx{{ChainID: 137, Fee: "0"}}, + OutTxs: []RelayTx{{ChainID: 8453, Fee: "0"}}, + }, + } + sw, err := ComputeMargin(context.Background(), req, newTestPricer()) + if err != nil { + t.Fatalf("ComputeMargin: %v", err) + } + if !sw.Priced { + t.Fatalf("USDC.E must price as stable peg, got priced=false") + } + approxEq(t, "volume", sw.VolumeUSD, 100) + approxEq(t, "margin", sw.MarginUSD, 0.5) +} + +// TestComputeMargin_ERC20ViaTokenLookup covers the new PriceTokenUSD +// path: a non-native, non-stable ERC-20 (UNI on Ethereum) is resolved +// by (chainId, contract address) rather than dropping silently. +func TestComputeMargin_ERC20ViaTokenLookup(t *testing.T) { + pricer := newTestPricer() + pricer.addToken(1, "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984", 8.0) // UNI = $8 + pricer.addToken(8453, "0x912ce59144191c1204e64559fe8253a0e49e6548", 1.2) // ARB-on-Base placeholder + + req := RelayRequest{ + ID: "swap-erc20", + Status: "success", + CreatedAt: "2026-05-23T12:00:00Z", + Data: RelayReqData{ + Metadata: RelayMetadata{ + CurrencyIn: RelayAmount{ + Currency: RelayCurrency{ChainID: 1, Symbol: "UNI", Address: "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984", Decimals: 18}, + Amount: "10000000000000000000", // 10 UNI = $80 + }, + CurrencyOut: RelayAmount{ + Currency: RelayCurrency{ChainID: 8453, Symbol: "ARB", Address: "0x912ce59144191c1204e64559fe8253a0e49e6548", Decimals: 18}, + Amount: "65000000000000000000", // 65 tokens × $1.2 = $78 + }, + }, + InTxs: []RelayTx{{ChainID: 1, Fee: "0"}}, + OutTxs: []RelayTx{{ChainID: 8453, Fee: "0"}}, + }, + } + sw, err := ComputeMargin(context.Background(), req, pricer) + if err != nil { + t.Fatalf("ComputeMargin: %v", err) + } + if !sw.Priced { + t.Fatalf("ERC-20 via PriceTokenUSD must price, got priced=false") + } + approxEq(t, "volume", sw.VolumeUSD, 80) + approxEq(t, "margin", sw.MarginUSD, 2) +} + +// TestComputeMargin_RelayUSDHintShortCircuits proves that when Relay +// populates amountUsd on a leg, we use it verbatim and DON'T burn a +// pricer call. The pricer is intentionally empty so any pricer touch +// would flip priced=false. +func TestComputeMargin_RelayUSDHintShortCircuits(t *testing.T) { + emptyPricer := newStaticPricer(nil) // knows literally nothing + + req := RelayRequest{ + ID: "swap-hint", + Status: "success", + CreatedAt: "2026-05-23T12:00:00Z", + Data: RelayReqData{ + Metadata: RelayMetadata{ + CurrencyIn: RelayAmount{ + Currency: RelayCurrency{ChainID: 1, Symbol: "PEPE", Address: "0x6982508145454ce325ddbe47a25d4ec3d2311933", Decimals: 18}, + Amount: "1000000000000000000000000", + AmountUSD: "152.34", // ← Relay's own swap-time USD + }, + CurrencyOut: RelayAmount{ + Currency: RelayCurrency{ChainID: 8453, Symbol: "USDC", Decimals: 6}, + Amount: "150000000", + AmountUSD: "150.00", + }, + }, + InTxs: []RelayTx{{ChainID: 1, Fee: "0"}}, + OutTxs: []RelayTx{{ChainID: 8453, Fee: "0"}}, + }, + } + sw, err := ComputeMargin(context.Background(), req, emptyPricer) + if err != nil { + t.Fatalf("ComputeMargin: %v", err) + } + if !sw.Priced { + t.Fatalf("amountUsd hint must short-circuit, got priced=false") + } + approxEq(t, "volume", sw.VolumeUSD, 152.34) + approxEq(t, "margin", sw.MarginUSD, 2.34) +} + +// TestComputeMargin_RelayUSDHintFallback covers the case where Relay +// returns amountUsd="0" or empty: we must fall back to our pricer, not +// silently treat the leg as $0. +func TestComputeMargin_RelayUSDHintFallback(t *testing.T) { + req := RelayRequest{ + ID: "swap-no-hint", + Status: "success", + CreatedAt: "2026-05-23T12:00:00Z", + Data: RelayReqData{ + Metadata: RelayMetadata{ + CurrencyIn: RelayAmount{ + Currency: RelayCurrency{ChainID: 1, Symbol: "ETH", Decimals: 18}, + Amount: "1000000000000000000", + AmountUSD: "", // empty hint → fall through + }, + CurrencyOut: RelayAmount{ + Currency: RelayCurrency{ChainID: 8453, Symbol: "ETH", Decimals: 18}, + Amount: "990000000000000000", + AmountUSD: "0", // zero hint → also falls through + }, + }, + InTxs: []RelayTx{{ChainID: 1, Fee: "0"}}, + OutTxs: []RelayTx{{ChainID: 8453, Fee: "0"}}, + }, + } + sw, err := ComputeMargin(context.Background(), req, newTestPricer()) + if err != nil { + t.Fatalf("ComputeMargin: %v", err) + } + if !sw.Priced { + t.Fatalf("ETH legs must price via fallback, got priced=false") + } + approxEq(t, "volume", sw.VolumeUSD, 3000) + approxEq(t, "margin", sw.MarginUSD, 30) // 3000 - 2970 - 0 - 0 +} + +func TestScaleAmount_Precision(t *testing.T) { + v, ok := scaleAmount("1000000000000000000", 18) + if !ok { + t.Fatalf("scaleAmount returned ok=false") + } + approxEq(t, "1eth", v, 1.0) + v, ok = scaleAmount("500000000", 9) + if !ok { + t.Fatalf("scaleAmount returned ok=false") + } + approxEq(t, "half-sol", v, 0.5) + if _, ok := scaleAmount("not-a-number", 18); ok { + t.Errorf("expected scaleAmount to fail on garbage input") + } +} + +func TestParseRelayUSDHint(t *testing.T) { + cases := []struct { + in string + wantV float64 + wantOK bool + }{ + {"", 0, false}, + {" ", 0, false}, + {"0", 0, false}, + {"-1.5", 0, false}, + {"not a number", 0, false}, + {"1.23", 1.23, true}, + {" 150.50 ", 150.50, true}, + {"0.0000001", 0.0000001, true}, + } + for _, c := range cases { + v, ok := parseRelayUSDHint(c.in) + if ok != c.wantOK { + t.Errorf("parseRelayUSDHint(%q): ok=%v want %v", c.in, ok, c.wantOK) + } + if ok && math.Abs(v-c.wantV) > 1e-9 { + t.Errorf("parseRelayUSDHint(%q): v=%v want %v", c.in, v, c.wantV) + } + } +} diff --git a/harnesses/relay-revenue/cmd/script/integration_test.go b/harnesses/relay-revenue/cmd/script/integration_test.go new file mode 100644 index 00000000..11045723 --- /dev/null +++ b/harnesses/relay-revenue/cmd/script/integration_test.go @@ -0,0 +1,131 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "sort" + "strings" + "testing" + "time" +) + +func TestIntegration_RealRelayData(t *testing.T) { + if os.Getenv("MOBULA_API_KEY") == "" { + t.Skip("MOBULA_API_KEY not set") + } + pricer := buildPricer() + client := &http.Client{Timeout: 15 * time.Second} + + type page struct { + Requests []RelayRequest `json:"requests"` + Continuation string `json:"continuation"` + } + cont := "" + total := 0 + priced := 0 + dropBy := map[string]int{} + dropExamples := map[string]string{} + for p := 0; p < 4; p++ { + url := "https://api.relay.link/requests?limit=50&status=success" + if cont != "" { + url += "&continuation=" + cont + } + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("User-Agent", "mobula-openchainbench/integration") + resp, err := client.Do(req) + if err != nil { + t.Fatalf("fetch: %v", err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + var pg page + _ = json.Unmarshal(body, &pg) + for _, r := range pg.Requests { + if r.Status != "success" { + continue + } + total++ + ctx := context.Background() + sw, _ := ComputeMargin(ctx, r, pricer) + if sw.Priced { + priced++ + continue + } + // Categorize the drop. Probe each leg in isolation. + d := r.Data + tag := "" + if _, ok := amountToUSD(ctx, pricer, d.Metadata.CurrencyIn); !ok { + tag = "in:" + dropTag(d.Metadata.CurrencyIn.Currency) + } else if _, ok := amountToUSD(ctx, pricer, d.Metadata.CurrencyOut); !ok { + tag = "out:" + dropTag(d.Metadata.CurrencyOut.Currency) + } else if _, ok := sumGasUSD(ctx, pricer, d.InTxs, d.OutTxs); !ok { + failing := []string{} + for _, x := range append(append([]RelayTx{}, d.InTxs...), d.OutTxs...) { + if x.Fee == "" || x.Fee == "0" { + continue + } + cs, found := chainSpec(x.ChainID) + if !found { + failing = append(failing, fmt.Sprintf("chain%d=UNKNOWN", x.ChainID)) + continue + } + if _, pok := pricer.PriceUSD(ctx, cs.NativeCoingeckoID, cs.NativeSymbol); !pok { + failing = append(failing, fmt.Sprintf("chain%d:%s/%s", x.ChainID, cs.NativeSymbol, cs.NativeCoingeckoID)) + } + } + tag = "gas:" + strings.Join(failing, ",") + } else if _, ok := sumAppFeesUSD(ctx, pricer, d.AppFees); !ok { + if len(d.AppFees) > 0 { + tag = "appfee:" + dropTag(d.AppFees[0].Currency) + } else { + tag = "appfee:empty?" + } + } else { + tag = "unknown" + } + dropBy[tag]++ + if _, exists := dropExamples[tag]; !exists { + dropExamples[tag] = r.ID + } + } + cont = pg.Continuation + if cont == "" { + break + } + } + + pct := float64(priced) / float64(total) * 100 + t.Logf("LIVE: %d/%d priced = %.1f %%", priced, total, pct) + type kv struct { + k string + v int + } + var ranked []kv + for k, v := range dropBy { + ranked = append(ranked, kv{k, v}) + } + sort.Slice(ranked, func(i, j int) bool { return ranked[i].v > ranked[j].v }) + t.Logf("DROP CAUSES (top 15):") + for i, kv := range ranked { + if i >= 15 { + break + } + t.Logf(" %3d %-50s ex: %s", kv.v, kv.k, dropExamples[kv.k]) + } +} + +func dropTag(c RelayCurrency) string { + sym := c.Symbol + if sym == "" { + sym = "?" + } + addr := c.Address + if len(addr) > 12 { + addr = addr[:6] + "…" + addr[len(addr)-4:] + } + return fmt.Sprintf("chain=%d sym=%s addr=%s", c.ChainID, strings.ToUpper(sym), addr) +} diff --git a/harnesses/relay-revenue/cmd/script/loghub.go b/harnesses/relay-revenue/cmd/script/loghub.go new file mode 100644 index 00000000..9dd74488 --- /dev/null +++ b/harnesses/relay-revenue/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/relay-revenue/cmd/script/main.go b/harnesses/relay-revenue/cmd/script/main.go new file mode 100644 index 00000000..bd00a058 --- /dev/null +++ b/harnesses/relay-revenue/cmd/script/main.go @@ -0,0 +1,271 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "strconv" + "strings" + "syscall" + "time" +) + +// main.go — wiring layer. Owns: +// * the SQLite store (./data.db unless RELAY_DB_PATH set) +// * the HTTP server on :2112 (always — hard-coded per OCB convention) +// * the 60s polling loop, ctx-cancelled by SIGINT/SIGTERM +// * the 5min metrics refresh goroutine +// +// Backfill is LAZY by default — empty DB means we start from now. Set +// RELAY_BACKFILL_HOURS to opt into a deeper history walk. The bench +// page is expected to show "warming up" until 24h of data accumulate +// either way. + +const ( + defaultDBPath = "/app/data.db" + pollCadence = 60 * time.Second + lagWindowSeconds = 180 // re-fetch the 3-min p99 createdAt → success lag + listenAddr = ":2112" +) + +func main() { + installLogCapture() // capture stdout into /logs ring buffer + fmt.Println("=== Relay Revenue Harness ===") + fmt.Println("OpenChainBench — implied margin tracker for Relay.link cross-chain swaps.") + fmt.Println() + + dbPath := envDefault("RELAY_DB_PATH", defaultDBPath) + store, err := OpenStore(dbPath) + if err != nil { + fmt.Printf("[fatal] open store at %s: %v\n", dbPath, err) + os.Exit(1) + } + defer store.Close() + fmt.Printf("Store: %s\n", dbPath) + + backfillHours := envIntDefault("RELAY_BACKFILL_HOURS", 0) + if backfillHours < 0 { + backfillHours = 0 + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // HTTP server. Hardcoded :2112 per OCB convention; we deliberately + // ignore $PORT so the shared Prom can scrape on the expected port. + go func() { + fmt.Printf("Metrics + logs server: %s/metrics\n", listenAddr) + if err := startMetricsServer(listenAddr); err != nil { + fmt.Printf("[fatal] metrics server: %v\n", err) + os.Exit(1) + } + }() + + go startMetricsRefresh(ctx, store) + + client := NewRelayClient() + pricer := buildPricer() + + // Resolve resume point. Order of preference: + // 1. DB has data → max(created_at) - lag window + // 2. Backfill opt-in via env → now - hours + // 3. Lazy → 0 (loop won't filter by timestamp; will paginate + // until it hits a known id, which on first run = never; the + // relayPageMax cap protects us). + resumeAt, err := store.resumeSince(ctx) + if err != nil { + fmt.Printf("[warn] resume lookup failed: %v\n", err) + resumeAt = 0 + } + if resumeAt == 0 && backfillHours > 0 { + resumeAt = time.Now().Add(-time.Duration(backfillHours) * time.Hour).Unix() + fmt.Printf("Backfill: starting from %s (-%dh)\n", + time.Unix(resumeAt, 0).UTC().Format(time.RFC3339), backfillHours) + } else if resumeAt > 0 { + fmt.Printf("Resuming from %s (max - %ds)\n", + time.Unix(resumeAt, 0).UTC().Format(time.RFC3339), lagWindowSeconds) + } else { + fmt.Println("Lazy start: no DB history, no RELAY_BACKFILL_HOURS — accumulating from now.") + } + fmt.Println() + + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt, syscall.SIGTERM) + go func() { + s := <-sig + fmt.Printf("\n[shutdown] received %v\n", s) + cancel() + }() + + runPollLoop(ctx, client, pricer, store, resumeAt) + fmt.Println("[shutdown] poll loop exited cleanly") +} + +// runPollLoop is the supervisor. It calls runOneCycle() every +// pollCadence; each cycle walks pagination until either (a) we hit an +// id we already have, or (b) the per-cycle page cap fires. +func runPollLoop(ctx context.Context, client *RelayClient, pricer Pricer, store *Store, startResume int64) { + cycleStart := time.NewTimer(2 * time.Second) // first cycle fires fast + defer cycleStart.Stop() + ticker := time.NewTicker(pollCadence) + defer ticker.Stop() + resumeAt := startResume + + for { + select { + case <-ctx.Done(): + return + case <-cycleStart.C: + runOneCycle(ctx, client, pricer, store, &resumeAt) + case <-ticker.C: + runOneCycle(ctx, client, pricer, store, &resumeAt) + } + } +} + +// runOneCycle walks newest-first pagination until catch-up or page cap. +// Each fetched request goes through ComputeMargin; success swaps that +// price cleanly are upserted with priced=1, pending / unpriced swaps +// land as priced=0 placeholders so the next cycle can re-price them. +// +// The function is conservative w.r.t. the resume marker: it only +// advances resumeAt forward, never backward, so a transient failure +// can't permanently lose ground. +func runOneCycle(ctx context.Context, client *RelayClient, pricer Pricer, store *Store, resumeAt *int64) { + start := time.Now() + var ( + continuation = "" + newSuccess = 0 + newPending = 0 + pages = 0 + ) + maxSeen := *resumeAt + + for pages < relayPageMax { + select { + case <-ctx.Done(): + return + default: + } + + opts := FetchPageOptions{ + Continuation: continuation, + } + if *resumeAt > 0 && continuation == "" { + // Only set startTimestamp on the first request of a cycle; + // the continuation cursor preserves the timestamp filter + // server-side for subsequent pages. + opts.StartTimestamp = *resumeAt + } + + page, err := client.FetchPage(ctx, opts) + if err != nil { + fmt.Printf("[poll] fetch error: %v\n", err) + break + } + pages++ + + if len(page.Requests) == 0 { + break + } + + caughtUp := false + for _, r := range page.Requests { + exists, err := store.Exists(ctx, r.ID) + if err != nil { + fmt.Printf("[poll] store.exists(%s): %v\n", r.ID, err) + continue + } + if exists { + // We've reached known territory — stop paginating to + // avoid burning quota on already-seen pages. + caughtUp = true + break + } + + if !strings.EqualFold(r.Status, "success") { + // Pending / failure: record a thin row so we know we + // observed it; re-process on later cycle. + thin := Swap{ + ID: r.ID, + CreatedAt: parseTSOrNow(r.CreatedAt), + Status: r.Status, + ChainIn: r.Data.Metadata.CurrencyIn.Currency.ChainID, + ChainOut: r.Data.Metadata.CurrencyOut.Currency.ChainID, + Priced: false, + } + if err := store.Upsert(ctx, thin); err != nil { + fmt.Printf("[poll] upsert pending %s: %v\n", r.ID, err) + } + newPending++ + if thin.CreatedAt > maxSeen { + maxSeen = thin.CreatedAt + } + continue + } + + sw, err := ComputeMargin(ctx, r, pricer) + if err != nil { + fmt.Printf("[poll] compute %s: %v\n", r.ID, err) + continue + } + if err := store.Upsert(ctx, sw); err != nil { + fmt.Printf("[poll] upsert %s: %v\n", r.ID, err) + continue + } + newSuccess++ + if sw.CreatedAt > maxSeen { + maxSeen = sw.CreatedAt + } + } + + if caughtUp || page.Continuation == "" { + break + } + continuation = page.Continuation + } + + // Advance resume marker by max(seen) - lag window. Only ever + // forward, never back. + if maxSeen > 0 { + candidate := maxSeen - lagWindowSeconds + if candidate > *resumeAt { + *resumeAt = candidate + } + } + + dur := time.Since(start) + relayLastPollTimestamp.Set(float64(time.Now().Unix())) + relayPollDurationSeconds.Set(dur.Seconds()) + relayNewSwapsPerCycle.Set(float64(newSuccess)) + fmt.Printf("[poll] cycle done in %s: pages=%d new_success=%d new_pending=%d resume=%d\n", + dur.Round(time.Millisecond), pages, newSuccess, newPending, *resumeAt) +} + +func parseTSOrNow(s string) int64 { + v, err := parseRelayTimestamp(s) + if err != nil { + return time.Now().Unix() + } + return v +} + +func envDefault(key, def string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return def +} + +func envIntDefault(key string, def int) int { + v := strings.TrimSpace(os.Getenv(key)) + if v == "" { + return def + } + n, err := strconv.Atoi(v) + if err != nil { + return def + } + return n +} diff --git a/harnesses/relay-revenue/cmd/script/metrics.go b/harnesses/relay-revenue/cmd/script/metrics.go new file mode 100644 index 00000000..b0604919 --- /dev/null +++ b/harnesses/relay-revenue/cmd/script/metrics.go @@ -0,0 +1,199 @@ +package main + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +// metrics.go — Prom surface for the Relay revenue bench. +// +// Per OCB convention every harness binds :2112 and exposes +// /metrics, /health, /logs. Naming prefix `relay_` to keep the +// global namespace clean. + +var ( + // Sliding-window aggregates over priced swaps. The polling loop + // upserts into SQLite; a background goroutine queries the DB on a + // 5-min cadence and SET()s these gauges. + relayRevenueUSD = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "relay_revenue_usd", + Help: "Sum of implied_margin_usd over the window. Computed as usd_in - usd_out - gas - appFees per swap, then summed over swaps with status='success' AND priced=1.", + ConstLabels: prometheus.Labels{"provider": "relay"}, + }, + []string{"window"}, + ) + relayVolumeUSD = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "relay_volume_usd", + Help: "Sum of usd_in (data.metadata.currencyIn priced via spot) over the window.", + ConstLabels: prometheus.Labels{"provider": "relay"}, + }, + []string{"window"}, + ) + relaySwapCount = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "relay_swap_count", + Help: "Count of priced success swaps in the window.", + ConstLabels: prometheus.Labels{"provider": "relay"}, + }, + []string{"window"}, + ) + relayTakeRateBps = promauto.NewGauge( + prometheus.GaugeOpts{ + Name: "relay_take_rate_bps", + Help: "implied_margin / volume in basis points over the rolling 24h window.", + ConstLabels: prometheus.Labels{"provider": "relay"}, + }, + ) + + // Pricing coverage counters — keep snapshots in DB but surface + // totals here so dashboards can flag a sudden spike in unpriced + // swaps (e.g. CoinGecko down). + relaySwapsPricedTotal = promauto.NewGauge( + prometheus.GaugeOpts{ + Name: "relay_swaps_priced_total", + Help: "Cumulative count of success swaps stored with priced=1. Reflects DB state, not a counter — re-scraped from SQLite each refresh.", + ConstLabels: prometheus.Labels{"provider": "relay"}, + }, + ) + relaySwapsUnpricedTotal = promauto.NewGauge( + prometheus.GaugeOpts{ + Name: "relay_swaps_unpriced_total", + Help: "Cumulative count of success swaps stored with priced=0 (at least one leg failed to price).", + ConstLabels: prometheus.Labels{"provider": "relay"}, + }, + ) + + // API hygiene counters. + relayAPIRequestTotal = promauto.NewCounter( + prometheus.CounterOpts{ + Name: "relay_api_request_total", + Help: "Total GET https://api.relay.link/requests calls since process start.", + ConstLabels: prometheus.Labels{"provider": "relay"}, + }, + ) + relayAPIErrorsTotal = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "relay_api_errors_total", + Help: "Total upstream errors broken out by error class.", + ConstLabels: prometheus.Labels{"provider": "relay"}, + }, + []string{"reason"}, + ) + + relayLastPollTimestamp = promauto.NewGauge( + prometheus.GaugeOpts{ + Name: "relay_last_poll_timestamp_seconds", + Help: "Unix timestamp of the most recent successful poll cycle. Stalls flag a frozen worker.", + ConstLabels: prometheus.Labels{"provider": "relay"}, + }, + ) + relayPollDurationSeconds = promauto.NewGauge( + prometheus.GaugeOpts{ + Name: "relay_poll_duration_seconds", + Help: "Wall-clock duration of the most recent poll cycle (paginating until catch-up).", + ConstLabels: prometheus.Labels{"provider": "relay"}, + }, + ) + relayNewSwapsPerCycle = promauto.NewGauge( + prometheus.GaugeOpts{ + Name: "relay_new_swaps_per_cycle", + Help: "Count of newly priced success swaps in the most recent poll cycle.", + ConstLabels: prometheus.Labels{"provider": "relay"}, + }, + ) + + // Sanity-bound rejections. A non-zero rate here means either Relay + // is emitting a corrupted amountUsd hint, an oracle returned a + // garbage price, or a token's reported decimals don't match its + // actual unit. Labels: hint|amount|gas|appfee|margin. + relaySwapsDroppedImplausible = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "relay_swaps_dropped_implausible_total", + Help: "Swaps (or legs) dropped because a USD value exceeded the per-leg / per-margin sanity envelope.", + ConstLabels: prometheus.Labels{"provider": "relay"}, + }, + []string{"reason"}, + ) +) + +// metricsWindow is a (label, retention) pair for the refresh goroutine. +type metricsWindow struct { + label string + duration time.Duration +} + +var metricsWindows = []metricsWindow{ + {"24h", 24 * time.Hour}, + {"7d", 7 * 24 * time.Hour}, + {"30d", 30 * 24 * time.Hour}, +} + +// startMetricsRefresh runs forever, querying the store on a 5-min +// cadence and updating the window gauges. Cancelled by the parent ctx. +func startMetricsRefresh(ctx context.Context, store *Store) { + tick := time.NewTicker(5 * time.Minute) + defer tick.Stop() + // Fire once shortly after boot so /metrics isn't all-zero for the + // first 5 minutes — important for visual debugging during deploys. + timer := time.NewTimer(15 * time.Second) + defer timer.Stop() + for { + select { + case <-ctx.Done(): + return + case <-timer.C: + refreshAggregates(ctx, store) + case <-tick.C: + refreshAggregates(ctx, store) + } + } +} + +func refreshAggregates(ctx context.Context, store *Store) { + now := time.Now().Unix() + for _, w := range metricsWindows { + since := now - int64(w.duration.Seconds()) + agg, err := store.AggregateSince(ctx, since) + if err != nil { + fmt.Printf("[metrics] aggregate %s failed: %v\n", w.label, err) + continue + } + relayRevenueUSD.WithLabelValues(w.label).Set(agg.MarginUSD) + relayVolumeUSD.WithLabelValues(w.label).Set(agg.VolumeUSD) + relaySwapCount.WithLabelValues(w.label).Set(float64(agg.Count)) + if w.label == "24h" { + if agg.VolumeUSD > 0 { + relayTakeRateBps.Set((agg.MarginUSD / agg.VolumeUSD) * 10000.0) + } else { + relayTakeRateBps.Set(0) + } + } + } + if priced, unpriced, err := store.PricedCounts(ctx); err == nil { + relaySwapsPricedTotal.Set(float64(priced)) + relaySwapsUnpricedTotal.Set(float64(unpriced)) + } +} + +// startMetricsServer binds /metrics + /logs + /health on addr. +// Blocking — run in its own goroutine. +func startMetricsServer(addr string) error { + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.Handler()) + mux.Handle("/logs", logsHandler()) + mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("ok")) + }) + mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("relay-revenue harness · OpenChainBench")) + }) + return http.ListenAndServe(addr, mux) +} diff --git a/harnesses/relay-revenue/cmd/script/pricer.go b/harnesses/relay-revenue/cmd/script/pricer.go new file mode 100644 index 00000000..2d267154 --- /dev/null +++ b/harnesses/relay-revenue/cmd/script/pricer.go @@ -0,0 +1,338 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +// pricer.go — USD spot prices. Two lookup axes: +// 1. by CoinGecko slug ("ethereum", "solana", …) for natives + listed assets +// 2. by (chainId, contract address) for any ERC-20 / SPL token +// Both axes route through a small in-process TTL cache to keep us off +// the free-tier rate limit on a 1/min poll cadence. +// +// USDC and other dollar pegs short-circuit to $1 to avoid burning a +// price call on every appFee. + +const ( + pricerTTL = 60 * time.Second + pricerNegTTL = 5 * time.Second // failed lookups cache briefly so we don't poison the price-path on a transient 429 + coinGeckoBaseURL = "https://api.coingecko.com/api/v3" + pricerHTTPTO = 8 * time.Second +) + +// stablePegs are symbols we treat as exactly $1 — no upstream call. +// Includes bridged variants (USDC.E on Polygon, USDbC on Base) and the +// less-liquid pegs Relay sometimes routes (USDe, GHO, FRAX). Symbol +// match is case-insensitive (we uppercase before lookup). +var stablePegs = map[string]bool{ + // Major + "USDC": true, + "USDT": true, + "DAI": true, + "FDUSD": true, + "PYUSD": true, + // Bridged USDC variants — Relay returns these as separate symbols + // from the chain's native USDC, so a strict equality match drops + // them. USDC.E (Polygon bridged) alone accounts for ~27 % of the + // audited drop population. + "USDC.E": true, + "USDC.e": true, + "USDBC": true, + "USDB": true, + // USD-pegged non-Circle stables observed in the swap stream. + "USDE": true, // Ethena + "SUSDE": true, // staked Ethena + "FRAX": true, + "GHO": true, + "USDP": true, + "TUSD": true, + "LUSD": true, + "USDD": true, + "USDS": true, + "MUSD": true, // MetaMask USD on Linea +} + +// chainSlug carries the per-source chain identifier each upstream +// pricer needs to scope a token-address lookup. Mobula and CoinGecko +// use different slug vocabularies even when they index the same chain. +type chainSlug struct { + mobula string // for /market/data?asset=&blockchain= + coingecko string // for /simple/token_price/{platform}/?contract_addresses=... +} + +// chainSlugs maps Relay's numeric chainId to the slug each upstream +// pricer expects. Keep in sync with chains.go (the native-pricing map); +// adding a chain here only enables ERC-20 lookup, gas still needs an +// entry in chains.go to price the native. +// +// Slug verification (probed against live Mobula API): +// - Optimism: Mobula uses "Optimistic" (titlecase) — confirmed via probe. +// - Ronin, Soneium, Monad mainnet: HTTP 400 "Invalid blockchain" — omit. +// - Bitcoin: no concept of ERC-20-style contracts; omit (gas-only). +var chainSlugs = map[int64]chainSlug{ + 1: {mobula: "ethereum", coingecko: "ethereum"}, + 10: {mobula: "Optimistic", coingecko: "optimistic-ethereum"}, + 56: {mobula: "BNB Smart Chain (BEP20)", coingecko: "binance-smart-chain"}, + 130: {mobula: "unichain", coingecko: "unichain"}, + 137: {mobula: "polygon", coingecko: "polygon-pos"}, + 288: {mobula: "boba", coingecko: "boba"}, + 324: {mobula: "zksync", coingecko: "zksync"}, + 480: {mobula: "worldchain", coingecko: "world-chain"}, + 1088: {mobula: "metis", coingecko: "metis-andromeda"}, + 1101: {mobula: "polygon-zkevm", coingecko: "polygon-zkevm"}, + 1135: {mobula: "lisk", coingecko: "lisk"}, + 1329: {mobula: "sei-evm", coingecko: "sei-network"}, + 5000: {mobula: "mantle", coingecko: "mantle"}, + 7777777: {mobula: "zora", coingecko: "zora-network"}, + 8333: {mobula: "b3", coingecko: "b3"}, + 8453: {mobula: "base", coingecko: "base"}, + 33139: {mobula: "apechain", coingecko: "apechain"}, + 34443: {mobula: "mode", coingecko: "mode"}, + 42161: {mobula: "arbitrum", coingecko: "arbitrum-one"}, + 43114: {mobula: "avalanche", coingecko: "avalanche"}, + 57073: {mobula: "ink", coingecko: "ink"}, + 59144: {mobula: "linea", coingecko: "linea"}, + 60808: {mobula: "bob", coingecko: "bob-network"}, + 80094: {mobula: "berachain", coingecko: "berachain"}, + 81457: {mobula: "blast", coingecko: "blast"}, + 534352: {mobula: "scroll", coingecko: "scroll"}, + 666666666: {mobula: "degen", coingecko: "degen"}, + 792703809: {mobula: "solana", coingecko: "solana"}, + // Abstract, Plume, TRON token-address slugs (gas-only support if + // the slug rejects 400; harness still prices natives via PriceUSD). + 2741: {mobula: "abstract", coingecko: "abstract"}, + 98866: {mobula: "plume", coingecko: "plume-network"}, + 728126428: {mobula: "tron", coingecko: "tron"}, + 25: {mobula: "cronos", coingecko: "cronos"}, + 143: {mobula: "monad", coingecko: "monad"}, + 360: {mobula: "shape", coingecko: "shape"}, + 48900: {mobula: "zircuit", coingecko: "zircuit"}, + 42018: {mobula: "mythos", coingecko: ""}, // CG doesn't list a Mythos platform; Mobula only + 747474: {mobula: "katana", coingecko: "katana"}, + 685689: {mobula: "gensyn", coingecko: ""}, +} + +// Pricer is the public interface. Two lookup methods: +// PriceUSD: by canonical slug (CG id) + optional symbol fallback. +// PriceTokenUSD: by (chainId, contract address) — required for ERC-20s +// that don't have a CG slug or aren't on the stable allowlist. +type Pricer interface { + PriceUSD(ctx context.Context, coingeckoID, symbol string) (float64, bool) + PriceTokenUSD(ctx context.Context, chainID int64, address, symbol string) (float64, bool) +} + +// cachedPrice is a single entry in the in-mem cache. +type cachedPrice struct { + usd float64 + expires time.Time + gotPrice bool +} + +// CoinGeckoPricer is the fallback implementation. Two endpoints used: +// - /simple/price?ids= for native + listed assets +// - /simple/token_price/{platform}?... for ERC-20 by contract address +type CoinGeckoPricer struct { + baseURL string + http *http.Client + mu sync.Mutex + cache map[string]cachedPrice + now func() time.Time +} + +func NewCoinGeckoPricer() *CoinGeckoPricer { + return &CoinGeckoPricer{ + baseURL: coinGeckoBaseURL, + http: &http.Client{Timeout: pricerHTTPTO}, + cache: map[string]cachedPrice{}, + now: time.Now, + } +} + +// PriceUSD: stable symbol short-circuit → cache → upstream. +func (p *CoinGeckoPricer) PriceUSD(ctx context.Context, coingeckoID, symbol string) (float64, bool) { + if symbol != "" && stablePegs[strings.ToUpper(symbol)] { + return 1.0, true + } + if coingeckoID == "" { + return 0, false + } + + p.mu.Lock() + if c, ok := p.cache[coingeckoID]; ok && p.now().Before(c.expires) { + p.mu.Unlock() + return c.usd, c.gotPrice + } + p.mu.Unlock() + + price, ok := p.fetchOne(ctx, coingeckoID) + p.mu.Lock() + ttl := pricerTTL + if !ok { + ttl = pricerNegTTL + } + p.cache[coingeckoID] = cachedPrice{usd: price, expires: p.now().Add(ttl), gotPrice: ok} + p.mu.Unlock() + return price, ok +} + +// PriceTokenUSD: stable symbol short-circuit → (chainId, address) cache → +// /simple/token_price/{platform} upstream. Unknown chains return false. +func (p *CoinGeckoPricer) PriceTokenUSD(ctx context.Context, chainID int64, address, symbol string) (float64, bool) { + if symbol != "" && stablePegs[strings.ToUpper(symbol)] { + return 1.0, true + } + slug, ok := chainSlugs[chainID] + if !ok || slug.coingecko == "" || address == "" { + return 0, false + } + key := fmt.Sprintf("cg:%d:%s", chainID, strings.ToLower(address)) + + p.mu.Lock() + if c, ok := p.cache[key]; ok && p.now().Before(c.expires) { + p.mu.Unlock() + return c.usd, c.gotPrice + } + p.mu.Unlock() + + price, priced := p.fetchToken(ctx, slug.coingecko, address) + ttl := pricerTTL + if !priced { + ttl = pricerNegTTL + } + p.mu.Lock() + p.cache[key] = cachedPrice{usd: price, expires: p.now().Add(ttl), gotPrice: priced} + p.mu.Unlock() + return price, priced +} + +func (p *CoinGeckoPricer) fetchOne(ctx context.Context, id string) (float64, bool) { + q := url.Values{} + q.Set("ids", id) + q.Set("vs_currencies", "usd") + u := p.baseURL + "/simple/price?" + q.Encode() + + body, ok := p.doJSON(ctx, u) + if !ok { + return 0, false + } + out := map[string]map[string]float64{} + if err := json.Unmarshal(body, &out); err != nil { + return 0, false + } + row, ok := out[id] + if !ok { + return 0, false + } + usd, ok := row["usd"] + if !ok || usd <= 0 { + return 0, false + } + return usd, true +} + +func (p *CoinGeckoPricer) fetchToken(ctx context.Context, platform, address string) (float64, bool) { + q := url.Values{} + q.Set("contract_addresses", address) + q.Set("vs_currencies", "usd") + u := p.baseURL + "/simple/token_price/" + platform + "?" + q.Encode() + + body, ok := p.doJSON(ctx, u) + if !ok { + return 0, false + } + out := map[string]map[string]float64{} + if err := json.Unmarshal(body, &out); err != nil { + return 0, false + } + // CG lowercases the address in the response key. + row, ok := out[strings.ToLower(address)] + if !ok { + return 0, false + } + usd, ok := row["usd"] + if !ok || usd <= 0 { + return 0, false + } + return usd, true +} + +func (p *CoinGeckoPricer) doJSON(ctx context.Context, u string) ([]byte, bool) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return nil, false + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "mobula-openchainbench/relay-revenue (contact@mobula.io)") + + resp, err := p.http.Do(req) + if err != nil { + return nil, false + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + _, _ = io.Copy(io.Discard, resp.Body) + return nil, false + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, false + } + return body, true +} + +// staticPricer is a deterministic in-memory pricer used in tests so we +// never hit the network. Lookup is exact-match on coingeckoID first, +// then fallback to upper-cased symbol. PriceTokenUSD uses a separate +// (chainId, address) map authored by the test. +type staticPricer struct { + prices map[string]float64 + tokens map[string]float64 // "chainId:lowerAddress" → price +} + +func newStaticPricer(prices map[string]float64) *staticPricer { + cp := map[string]float64{} + for k, v := range prices { + cp[k] = v + } + return &staticPricer{prices: cp, tokens: map[string]float64{}} +} + +// addToken seeds a (chainId, address) price for the test pricer. +func (s *staticPricer) addToken(chainID int64, address string, price float64) { + s.tokens[fmt.Sprintf("%d:%s", chainID, strings.ToLower(address))] = price +} + +func (s *staticPricer) PriceUSD(_ context.Context, coingeckoID, symbol string) (float64, bool) { + if symbol != "" && stablePegs[strings.ToUpper(symbol)] { + return 1.0, true + } + if coingeckoID != "" { + if v, ok := s.prices[coingeckoID]; ok { + return v, true + } + } + if symbol != "" { + if v, ok := s.prices[strings.ToUpper(symbol)]; ok { + return v, true + } + } + return 0, false +} + +func (s *staticPricer) PriceTokenUSD(_ context.Context, chainID int64, address, symbol string) (float64, bool) { + if symbol != "" && stablePegs[strings.ToUpper(symbol)] { + return 1.0, true + } + if v, ok := s.tokens[fmt.Sprintf("%d:%s", chainID, strings.ToLower(address))]; ok { + return v, true + } + return 0, false +} diff --git a/harnesses/relay-revenue/cmd/script/pricer_mobula.go b/harnesses/relay-revenue/cmd/script/pricer_mobula.go new file mode 100644 index 00000000..790287a7 --- /dev/null +++ b/harnesses/relay-revenue/cmd/script/pricer_mobula.go @@ -0,0 +1,215 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "sync" + "time" +) + +// pricer_mobula.go — Mobula price oracle implementation. Preferred over +// the CoinGecko fallback because: +// - Larger long-tail coverage (Relay routes through ~24 chains, many of +// which CoinGecko doesn't price natively — Mobula's indexer does) +// - Sub-15s freshness vs CG free-tier ~60s +// - No rate-limit concerns for our 1/min polling cadence +// - Methodology-defensible: "we use Mobula's own oracle" rather than +// a third-party with its own quirks +// +// Activated when MOBULA_API_KEY is set. Falls back to CoinGecko otherwise. + +const ( + mobulaBaseURL = "https://api.mobula.io/api/1" + mobulaHTTPTO = 8 * time.Second +) + +// MobulaPricer talks to https://api.mobula.io for USD prices via two +// query shapes against the same /market/data endpoint: +// PriceUSD → asset= (e.g. "ethereum") +// PriceTokenUSD → asset=&blockchain= (any ERC-20 / SPL) +type MobulaPricer struct { + apiKey string + baseURL string + http *http.Client + mu sync.Mutex + cache map[string]cachedPrice + now func() time.Time +} + +// NewMobulaPricer reads MOBULA_API_KEY from env. Returns (nil, false) if +// no key is set so the caller can fall back cleanly. +func NewMobulaPricer() (*MobulaPricer, bool) { + key := strings.TrimSpace(os.Getenv("MOBULA_API_KEY")) + if key == "" { + return nil, false + } + return &MobulaPricer{ + apiKey: key, + baseURL: mobulaBaseURL, + http: &http.Client{Timeout: mobulaHTTPTO}, + cache: map[string]cachedPrice{}, + now: time.Now, + }, true +} + +// PriceUSD: stable peg short-circuit → cache → Mobula upstream. +// The `coingeckoID` parameter doubles as the Mobula `asset` slug here +// since both APIs share CoinMarketCap-style slugs for major tokens +// (ethereum, usd-coin, solana, matic-network, etc.). If a service emits +// a coingecko id that Mobula doesn't recognise we treat it as unpriced — +// the CG fallback at the chained-pricer level catches those cases. +func (p *MobulaPricer) PriceUSD(ctx context.Context, coingeckoID, symbol string) (float64, bool) { + if symbol != "" && stablePegs[strings.ToUpper(symbol)] { + return 1.0, true + } + if coingeckoID == "" { + return 0, false + } + key := "slug:" + coingeckoID + + p.mu.Lock() + if c, ok := p.cache[key]; ok && p.now().Before(c.expires) { + p.mu.Unlock() + return c.usd, c.gotPrice + } + p.mu.Unlock() + + q := url.Values{} + q.Set("asset", coingeckoID) + price, ok := p.fetchPrice(ctx, q) + ttl := pricerTTL + if !ok { + ttl = pricerNegTTL + } + p.mu.Lock() + p.cache[key] = cachedPrice{usd: price, expires: p.now().Add(ttl), gotPrice: ok} + p.mu.Unlock() + return price, ok +} + +// PriceTokenUSD: contract-address lookup. Empirically covers ~95 % of +// the ERC-20s Relay routes on supported chains (audit, May 2026); the +// rest are freshly-minted memecoins not yet indexed. +func (p *MobulaPricer) PriceTokenUSD(ctx context.Context, chainID int64, address, symbol string) (float64, bool) { + if symbol != "" && stablePegs[strings.ToUpper(symbol)] { + return 1.0, true + } + slug, ok := chainSlugs[chainID] + if !ok || slug.mobula == "" || address == "" { + return 0, false + } + key := fmt.Sprintf("mob:%d:%s", chainID, strings.ToLower(address)) + + p.mu.Lock() + if c, ok := p.cache[key]; ok && p.now().Before(c.expires) { + p.mu.Unlock() + return c.usd, c.gotPrice + } + p.mu.Unlock() + + q := url.Values{} + q.Set("asset", address) + q.Set("blockchain", slug.mobula) + price, priced := p.fetchPrice(ctx, q) + ttl := pricerTTL + if !priced { + ttl = pricerNegTTL + } + p.mu.Lock() + p.cache[key] = cachedPrice{usd: price, expires: p.now().Add(ttl), gotPrice: priced} + p.mu.Unlock() + return price, priced +} + +func (p *MobulaPricer) fetchPrice(ctx context.Context, q url.Values) (float64, bool) { + u := p.baseURL + "/market/data?" + q.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return 0, false + } + req.Header.Set("Accept", "application/json") + // Mobula auth is the raw key in the Authorization header — no Bearer prefix. + req.Header.Set("Authorization", p.apiKey) + + resp, err := p.http.Do(req) + if err != nil { + return 0, false + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + _, _ = io.Copy(io.Discard, resp.Body) + return 0, false + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return 0, false + } + var out struct { + Data struct { + Price float64 `json:"price"` + } `json:"data"` + } + if err := json.Unmarshal(body, &out); err != nil { + return 0, false + } + if out.Data.Price <= 0 { + return 0, false + } + return out.Data.Price, true +} + +// chainedPricer queries pricers in order; first one to return a price +// wins. Used so we get Mobula-first coverage with a CG fallback for any +// asset Mobula doesn't know about. +type chainedPricer struct { + pricers []Pricer +} + +func newChainedPricer(ps ...Pricer) *chainedPricer { + return &chainedPricer{pricers: ps} +} + +func (c *chainedPricer) PriceUSD(ctx context.Context, coingeckoID, symbol string) (float64, bool) { + if symbol != "" && stablePegs[strings.ToUpper(symbol)] { + return 1.0, true + } + for _, p := range c.pricers { + if v, ok := p.PriceUSD(ctx, coingeckoID, symbol); ok { + return v, true + } + } + return 0, false +} + +func (c *chainedPricer) PriceTokenUSD(ctx context.Context, chainID int64, address, symbol string) (float64, bool) { + if symbol != "" && stablePegs[strings.ToUpper(symbol)] { + return 1.0, true + } + for _, p := range c.pricers { + if v, ok := p.PriceTokenUSD(ctx, chainID, address, symbol); ok { + return v, true + } + } + return 0, false +} + +// buildPricer wires up the active pricer for the harness, with Mobula +// preferred when MOBULA_API_KEY is set, and CoinGecko always available +// as fallback. Logs to stdout so operators can see which path is hot. +func buildPricer() Pricer { + cg := NewCoinGeckoPricer() + if mob, ok := NewMobulaPricer(); ok { + fmt.Println("[pricer] Mobula oracle ACTIVE (CoinGecko fallback wired)") + return newChainedPricer(mob, cg) + } + fmt.Println("[pricer] MOBULA_API_KEY not set — using CoinGecko free tier only") + return cg +} diff --git a/harnesses/relay-revenue/cmd/script/store.go b/harnesses/relay-revenue/cmd/script/store.go new file mode 100644 index 00000000..cb744fee --- /dev/null +++ b/harnesses/relay-revenue/cmd/script/store.go @@ -0,0 +1,213 @@ +package main + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + _ "modernc.org/sqlite" +) + +// store.go — SQLite persistence for priced + pending swaps. +// +// Schema: +// swaps( +// id TEXT PRIMARY KEY, +// created_at INTEGER NOT NULL, -- unix seconds +// status TEXT NOT NULL, +// volume_usd REAL NOT NULL, +// gas_usd REAL NOT NULL, +// appfees_usd REAL NOT NULL, +// margin_usd REAL NOT NULL, +// chain_in INTEGER NOT NULL, +// chain_out INTEGER NOT NULL, +// priced INTEGER NOT NULL -- 0/1, lets us re-price later +// ) +// +// We use modernc.org/sqlite (pure Go, CGO_DISABLED-safe so the +// Dockerfile keeps producing a static binary). + +// Store wraps a SQLite handle. Safe for concurrent use (SQLite +// serialises writes internally; the harness only has one writer +// goroutine anyway). +type Store struct { + db *sql.DB +} + +// OpenStore opens (or creates) the SQLite file at `path` and ensures +// the schema is migrated. The migration is idempotent. +func OpenStore(path string) (*Store, error) { + db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)") + if err != nil { + return nil, fmt.Errorf("open sqlite: %w", err) + } + if err := db.Ping(); err != nil { + return nil, fmt.Errorf("ping sqlite: %w", err) + } + if _, err := db.Exec(` + CREATE TABLE IF NOT EXISTS swaps ( + id TEXT PRIMARY KEY, + created_at INTEGER NOT NULL, + status TEXT NOT NULL, + volume_usd REAL NOT NULL DEFAULT 0, + gas_usd REAL NOT NULL DEFAULT 0, + appfees_usd REAL NOT NULL DEFAULT 0, + margin_usd REAL NOT NULL DEFAULT 0, + chain_in INTEGER NOT NULL DEFAULT 0, + chain_out INTEGER NOT NULL DEFAULT 0, + priced INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_swaps_created_at ON swaps(created_at); + CREATE INDEX IF NOT EXISTS idx_swaps_status ON swaps(status); + CREATE INDEX IF NOT EXISTS idx_swaps_priced ON swaps(priced); + `); err != nil { + return nil, fmt.Errorf("migrate schema: %w", err) + } + + // One-shot scrub of historical rows that the older, looser margin + // filter let through (June 2026 audit). Anything outside the new + // [-$5k, $10k] band was a pricing artifact and was polluting the + // 7d / 30d aggregates with persistent negative totals. Flip those + // rows to priced=0 so the rollups exclude them from now on; the + // rows stay in the table so the swap count and unpriced totals are + // accurate. Idempotent (running it after the data is clean is a + // no-op). + if _, err := db.Exec(` + UPDATE swaps + SET priced = 0, margin_usd = 0 + WHERE priced = 1 AND (margin_usd > 1e4 OR margin_usd < -5e3); + `); err != nil { + return nil, fmt.Errorf("scrub implausible margins: %w", err) + } + return &Store{db: db}, nil +} + +// Close releases the underlying handle. +func (s *Store) Close() error { + if s == nil || s.db == nil { + return nil + } + return s.db.Close() +} + +// Exists returns true if the swap is already persisted. Used by the +// polling loop to break out of pagination on the first known id (we +// only ever walk backwards from "now", so a hit means we've caught +// up). +func (s *Store) Exists(ctx context.Context, id string) (bool, error) { + row := s.db.QueryRowContext(ctx, `SELECT 1 FROM swaps WHERE id = ? LIMIT 1`, id) + var v int + switch err := row.Scan(&v); { + case err == nil: + return true, nil + case errors.Is(err, sql.ErrNoRows): + return false, nil + default: + return false, err + } +} + +// Upsert persists or replaces a swap. We upsert (not insert) so that +// re-priced swaps (status=success arriving later than the first poll) +// overwrite the earlier pending row. +func (s *Store) Upsert(ctx context.Context, sw Swap) error { + priced := 0 + if sw.Priced { + priced = 1 + } + _, err := s.db.ExecContext(ctx, ` + INSERT INTO swaps (id, created_at, status, volume_usd, gas_usd, appfees_usd, margin_usd, chain_in, chain_out, priced) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + status = excluded.status, + volume_usd = excluded.volume_usd, + gas_usd = excluded.gas_usd, + appfees_usd = excluded.appfees_usd, + margin_usd = excluded.margin_usd, + chain_in = excluded.chain_in, + chain_out = excluded.chain_out, + priced = excluded.priced + `, + sw.ID, sw.CreatedAt, sw.Status, + sw.VolumeUSD, sw.GasUSD, sw.AppFeesUSD, sw.MarginUSD, + sw.ChainIn, sw.ChainOut, priced, + ) + if err != nil { + return fmt.Errorf("upsert swap %s: %w", sw.ID, err) + } + return nil +} + +// MaxCreatedAt returns the unix-sec timestamp of the most recent swap +// in the DB, or 0 if the DB is empty. Used at startup to resume from +// (max-3min) so we re-fetch the lag window per spec. +func (s *Store) MaxCreatedAt(ctx context.Context) (int64, error) { + row := s.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(created_at), 0) FROM swaps`) + var v int64 + if err := row.Scan(&v); err != nil { + return 0, err + } + return v, nil +} + +// WindowAggregates is the aggregate the metrics layer needs per window. +type WindowAggregates struct { + VolumeUSD float64 + MarginUSD float64 + GasUSD float64 + FeesUSD float64 + Count int64 +} + +// AggregateSince returns volume/margin/count over swaps with +// status='success' and priced=1 created in the [since, now] range. +// Unpriced swaps are intentionally excluded — they would skew the +// take_rate downward by adding volume without margin. +func (s *Store) AggregateSince(ctx context.Context, since int64) (WindowAggregates, error) { + row := s.db.QueryRowContext(ctx, ` + SELECT + COALESCE(SUM(volume_usd), 0), + COALESCE(SUM(margin_usd), 0), + COALESCE(SUM(gas_usd), 0), + COALESCE(SUM(appfees_usd), 0), + COUNT(*) + FROM swaps + WHERE status = 'success' AND priced = 1 AND created_at >= ? + `, since) + var agg WindowAggregates + if err := row.Scan(&agg.VolumeUSD, &agg.MarginUSD, &agg.GasUSD, &agg.FeesUSD, &agg.Count); err != nil { + return WindowAggregates{}, err + } + return agg, nil +} + +// PricedCounts returns (priced_total, unpriced_total) over the whole DB. +// Used to surface "do we trust the take_rate?" via Prom counters. +func (s *Store) PricedCounts(ctx context.Context) (int64, int64, error) { + var priced, unpriced int64 + row := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM swaps WHERE status='success' AND priced=1`) + if err := row.Scan(&priced); err != nil { + return 0, 0, err + } + row = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM swaps WHERE status='success' AND priced=0`) + if err := row.Scan(&unpriced); err != nil { + return 0, 0, err + } + return priced, unpriced, nil +} + +// resumeSince computes the timestamp the polling loop should start +// from on boot: max(created_at) - 3 min (to re-fetch the lag window) +// or 0 when the DB is empty (fresh start = lazy backfill). +func (s *Store) resumeSince(ctx context.Context) (int64, error) { + maxAt, err := s.MaxCreatedAt(ctx) + if err != nil { + return 0, err + } + if maxAt == 0 { + return 0, nil + } + return maxAt - int64(3*time.Minute/time.Second), nil +} diff --git a/harnesses/relay-revenue/cmd/script/types.go b/harnesses/relay-revenue/cmd/script/types.go new file mode 100644 index 00000000..84e55364 --- /dev/null +++ b/harnesses/relay-revenue/cmd/script/types.go @@ -0,0 +1,87 @@ +package main + +// types.go — shared data types for the Relay.link revenue harness. +// +// Mirrors only the fields we actually consume from +// GET https://api.relay.link/requests. We intentionally keep the +// payload struct minimal (rather than mapping the full API) so that +// upstream schema additions don't break unmarshalling. + +// RelayPage is one page of /requests. +type RelayPage struct { + Requests []RelayRequest `json:"requests"` + Continuation string `json:"continuation"` +} + +// RelayRequest is a single cross-chain swap as returned by Relay. +type RelayRequest struct { + ID string `json:"id"` + Status string `json:"status"` // "success", "pending", "failure", ... + CreatedAt string `json:"createdAt"` // RFC3339 + UpdatedAt string `json:"updatedAt"` // RFC3339 + Data RelayReqData `json:"data"` +} + +// RelayReqData carries the cross-chain payload Relay exposes per swap. +type RelayReqData struct { + Metadata RelayMetadata `json:"metadata"` + InTxs []RelayTx `json:"inTxs"` + OutTxs []RelayTx `json:"outTxs"` + AppFees []RelayAppFee `json:"appFees"` + // FeeCurrency is a symbol-string ("usdc", "eth", …), NOT a RelayCurrency + // struct as one might guess. Per-appFee pricing uses RelayAppFee.Currency + // which IS a struct; this field is informational at the data level only. + FeeCurrency string `json:"feeCurrency"` +} + +// RelayMetadata.currencyIn / currencyOut carry the user-perceived swap +// (debited / credited). amount is decimal-formatted string. +type RelayMetadata struct { + CurrencyIn RelayAmount `json:"currencyIn"` + CurrencyOut RelayAmount `json:"currencyOut"` +} + +type RelayAmount struct { + Currency RelayCurrency `json:"currency"` + Amount string `json:"amount"` // raw smallest unit (wei/lamports) + AmountUSD string `json:"amountUsd"` // sometimes pre-priced by Relay; treat as hint, recompute when missing +} + +type RelayCurrency struct { + ChainID int64 `json:"chainId"` + Address string `json:"address"` + Symbol string `json:"symbol"` + Name string `json:"name"` + Decimals int `json:"decimals"` +} + +// RelayTx represents one inbound/outbound on-chain tx referenced by the swap. +// `fee` is the gas paid in the chain's native token, raw smallest unit. +type RelayTx struct { + Hash string `json:"hash"` + ChainID int64 `json:"chainId"` + Fee string `json:"fee"` +} + +// RelayAppFee is an external integrator/protocol fee (Relay routes it +// onchain on behalf of the integrator). Currency frequently differs +// from the swap currency — typically USDC. +type RelayAppFee struct { + Recipient string `json:"recipient"` + Amount string `json:"amount"` + Currency RelayCurrency `json:"currency"` +} + +// Swap is our internal, denormalized view used for storage + metrics. +type Swap struct { + ID string + CreatedAt int64 // unix sec + Status string + VolumeUSD float64 + GasUSD float64 + AppFeesUSD float64 + MarginUSD float64 + ChainIn int64 + ChainOut int64 + Priced bool +} diff --git a/harnesses/relay-revenue/go.mod b/harnesses/relay-revenue/go.mod new file mode 100644 index 00000000..9172ac47 --- /dev/null +++ b/harnesses/relay-revenue/go.mod @@ -0,0 +1,34 @@ +module relay-revenue + +go 1.24.0 + +require ( + github.com/prometheus/client_golang v1.23.2 + modernc.org/sqlite v1.34.4 +) + +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/kr/text v0.2.0 // 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.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect + golang.org/x/sys v0.35.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect + modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect + modernc.org/libc v1.61.4 // 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/relay-revenue/go.sum b/harnesses/relay-revenue/go.sum new file mode 100644 index 00000000..0d5f4d9c --- /dev/null +++ b/harnesses/relay-revenue/go.sum @@ -0,0 +1,95 @@ +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/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.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +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.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/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/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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +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/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk= +golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY= +golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= +golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/tools v0.25.0 h1:oFU9pkj/iJgs+0DT+VMHrx+oBKs/LJMV+Uvg78sl+fE= +golang.org/x/tools v0.25.0/go.mod h1:/vtpO8WL1N9cQC3FN5zPqb//fRXskFHbLKk4OW1Q7rg= +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= +modernc.org/cc/v4 v4.23.1 h1:WqJoPL3x4cUufQVHkXpXX7ThFJ1C4ik80i2eXEXbhD8= +modernc.org/cc/v4 v4.23.1/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.23.1 h1:N49a7JiWGWV7lkPE4yYcvjkBGZQi93/JabRYjdWmJXc= +modernc.org/ccgo/v4 v4.23.1/go.mod h1:JoIUegEIfutvoWV/BBfDFpPpfR2nc3U0jKucGcbmwDU= +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.5.0 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M= +modernc.org/gc/v2 v2.5.0/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.61.4 h1:wVyqEx6tlltte9lPTjq0kDAdtdM9c4JH8rU6M1ZVawA= +modernc.org/libc v1.61.4/go.mod h1:VfXVuM/Shh5XsMNrh3C6OkfL78G3loa4ZC/Ljv9k7xc= +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.4 h1:sjdARozcL5KJBvYQvLlZEmctRgW9xqIZc2ncN7PU0P8= +modernc.org/sqlite v1.34.4/go.mod h1:3QQFCG2SEMtc2nv+Wq4cQCH7Hjcg+p/RMlS1XK+zwbk= +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/relay-revenue/railway.toml b/harnesses/relay-revenue/railway.toml new file mode 100644 index 00000000..2abbb1f0 --- /dev/null +++ b/harnesses/relay-revenue/railway.toml @@ -0,0 +1,7 @@ +[build] +builder = "DOCKERFILE" +dockerfilePath = "Dockerfile" + +[deploy] +healthcheckPath = "/health" +restartPolicyType = "ON_FAILURE" diff --git a/harnesses/token-deployment-cost/cmd/script/config.go b/harnesses/token-deployment-cost/cmd/script/config.go index 4680f74a..fa94609f 100644 --- a/harnesses/token-deployment-cost/cmd/script/config.go +++ b/harnesses/token-deployment-cost/cmd/script/config.go @@ -55,31 +55,12 @@ func getenvDefault(key, def string) string { const defaultFrom = "0x000000000000000000000000000000000000dEaD" func loadConfig() *Config { - from := defaultFrom + _ = defaultFrom // kept for when EVM chains are re-enabled with real OZ v5 bytecode 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"}, @@ -90,7 +71,6 @@ func loadConfig() *Config { // 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"}, From 2795b1e87907fc3989cc7ceeea278848416b9275 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Fri, 3 Jul 2026 17:53:54 +0200 Subject: [PATCH 070/144] fix(hyperliquid-xyz): alias to hyperliquid logo + redirect product page --- src/app/products/[slug]/page.tsx | 6 ++++++ src/lib/logo-manifest.ts | 1 + 2 files changed, 7 insertions(+) diff --git a/src/app/products/[slug]/page.tsx b/src/app/products/[slug]/page.tsx index 3ad3525d..5b683ab0 100644 --- a/src/app/products/[slug]/page.tsx +++ b/src/app/products/[slug]/page.tsx @@ -69,6 +69,9 @@ export async function generateMetadata({ alternates: { canonical: canonicalUrl }, }; } + if (slug === "hyperliquid-xyz") { + return { alternates: { canonical: `${SITE.url}/products/hyperliquid` } }; + } const p = await getProvider(slug); if (!p) return {}; const reg = getProviderRegistry(p.slug); @@ -142,6 +145,9 @@ export default async function ProviderPage({ if (await isHlBuilderSlug(slug)) { redirect(`/hyperliquid/${slug}`); } + if (slug === "hyperliquid-xyz") { + redirect(`/products/hyperliquid`); + } const p = await getProvider(slug); if (!p) notFound(); const reg = getProviderRegistry(p.slug); diff --git a/src/lib/logo-manifest.ts b/src/lib/logo-manifest.ts index 08c49fb7..5543848a 100644 --- a/src/lib/logo-manifest.ts +++ b/src/lib/logo-manifest.ts @@ -301,6 +301,7 @@ const ALIASES: Record = { sol: "solana", bsc: "bnb", hypercore: "hyperliquid", // HyperCore is the Hyperliquid trading engine, same brand + "hyperliquid-xyz": "hyperliquid", // HIP-3 permissionless perp on Hyperliquid, same brand "publicnode-feehistory": "publicnode", // Same brand split across two HL builder addresses; one logo is enough. "metamask-alt": "metamask", From 15722cd83fa4ef8292f5cf1f0765c9c5379cb07e Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Fri, 3 Jul 2026 18:00:15 +0200 Subject: [PATCH 071/144] head-lag harness: port ops extras (grafana/prometheus/alertmanager configs, spike scripts, Makefile) (#881) Co-authored-by: Florent Tapponnier --- harnesses/aggregator-head-lag/.env.example | 12 + harnesses/aggregator-head-lag/Makefile | 151 +++ .../alertmanager/Dockerfile | 12 + .../alertmanager/alertmanager.yml | 36 + harnesses/aggregator-head-lag/assets/logo.png | Bin 0 -> 65169 bytes .../aggregator-head-lag/auto_clean_spikes.sh | 134 ++ .../aggregator-head-lag/cleanup-prometheus.sh | 27 + .../aggregator-head-lag/docker-compose.yml | 82 ++ .../aggregator-head-lag/grafana-entrypoint.sh | 29 + .../aggregator-head-lag/grafana/Dockerfile | 34 + .../disabled/mobula_pulse_vs_fasttrade.json | 595 +++++++++ .../dashboards/disabled/pulse_vs_codex.json | 661 ++++++++++ .../disabled/quote_api_latency.json | 916 +++++++++++++ .../grafana/dashboards/head_lag.json | 945 ++++++++++++++ .../grafana/dashboards/metadata_coverage.json | 1133 +++++++++++++++++ .../grafana/grafana-entrypoint.sh | 29 + .../provisioning/dashboards/dashboard.yml | 13 + .../provisioning/datasources/prometheus.yml | 10 + harnesses/aggregator-head-lag/list_spikes.sh | 35 + .../monitoring/alert_rules.yml | 127 ++ .../monitoring/alertmanager.yml | 36 + .../disabled/quote_api_latency.json | 819 ++++++++++++ .../grafana/dashboards/head_lag.json | 773 +++++++++++ .../provisioning/dashboards/dashboard.yml | 13 + .../provisioning/datasources/prometheus.yml | 10 + .../monitoring/prometheus.yml | 21 + .../aggregator-head-lag/prometheus/Dockerfile | 14 + .../prometheus/alert_rules.yml | 223 ++++ .../prometheus/alertmanager.yml | 36 + .../prometheus/prometheus.staging.yml | 26 + .../prometheus/prometheus.yml | 27 + 31 files changed, 6979 insertions(+) create mode 100644 harnesses/aggregator-head-lag/.env.example create mode 100644 harnesses/aggregator-head-lag/Makefile create mode 100644 harnesses/aggregator-head-lag/alertmanager/Dockerfile create mode 100644 harnesses/aggregator-head-lag/alertmanager/alertmanager.yml create mode 100644 harnesses/aggregator-head-lag/assets/logo.png create mode 100755 harnesses/aggregator-head-lag/auto_clean_spikes.sh create mode 100755 harnesses/aggregator-head-lag/cleanup-prometheus.sh create mode 100644 harnesses/aggregator-head-lag/docker-compose.yml create mode 100755 harnesses/aggregator-head-lag/grafana-entrypoint.sh create mode 100644 harnesses/aggregator-head-lag/grafana/Dockerfile create mode 100644 harnesses/aggregator-head-lag/grafana/dashboards/disabled/mobula_pulse_vs_fasttrade.json create mode 100644 harnesses/aggregator-head-lag/grafana/dashboards/disabled/pulse_vs_codex.json create mode 100644 harnesses/aggregator-head-lag/grafana/dashboards/disabled/quote_api_latency.json create mode 100644 harnesses/aggregator-head-lag/grafana/dashboards/head_lag.json create mode 100644 harnesses/aggregator-head-lag/grafana/dashboards/metadata_coverage.json create mode 100755 harnesses/aggregator-head-lag/grafana/grafana-entrypoint.sh create mode 100644 harnesses/aggregator-head-lag/grafana/provisioning/dashboards/dashboard.yml create mode 100644 harnesses/aggregator-head-lag/grafana/provisioning/datasources/prometheus.yml create mode 100755 harnesses/aggregator-head-lag/list_spikes.sh create mode 100644 harnesses/aggregator-head-lag/monitoring/alert_rules.yml create mode 100644 harnesses/aggregator-head-lag/monitoring/alertmanager.yml create mode 100644 harnesses/aggregator-head-lag/monitoring/grafana/dashboards/disabled/quote_api_latency.json create mode 100644 harnesses/aggregator-head-lag/monitoring/grafana/dashboards/head_lag.json create mode 100644 harnesses/aggregator-head-lag/monitoring/grafana/provisioning/dashboards/dashboard.yml create mode 100644 harnesses/aggregator-head-lag/monitoring/grafana/provisioning/datasources/prometheus.yml create mode 100644 harnesses/aggregator-head-lag/monitoring/prometheus.yml create mode 100644 harnesses/aggregator-head-lag/prometheus/Dockerfile create mode 100644 harnesses/aggregator-head-lag/prometheus/alert_rules.yml create mode 100644 harnesses/aggregator-head-lag/prometheus/alertmanager.yml create mode 100644 harnesses/aggregator-head-lag/prometheus/prometheus.staging.yml create mode 100644 harnesses/aggregator-head-lag/prometheus/prometheus.yml diff --git a/harnesses/aggregator-head-lag/.env.example b/harnesses/aggregator-head-lag/.env.example new file mode 100644 index 00000000..9286aa10 --- /dev/null +++ b/harnesses/aggregator-head-lag/.env.example @@ -0,0 +1,12 @@ +# CoinGecko API Key (Pro plan required for WebSocket) +COINGECKO_API_KEY=your_coingecko_api_key + +# Mobula API Key +MOBULA_API_KEY=your_mobula_api_key + +# Defined.fi Session Cookie (for Codex data) +# Optional: Will be auto-scraped anonymously if not provided +DEFINED_SESSION_COOKIE=your_defined_session_cookie + +# Grafana Admin Password (for production) +GF_SECURITY_ADMIN_PASSWORD=admin diff --git a/harnesses/aggregator-head-lag/Makefile b/harnesses/aggregator-head-lag/Makefile new file mode 100644 index 00000000..7c1b2c68 --- /dev/null +++ b/harnesses/aggregator-head-lag/Makefile @@ -0,0 +1,151 @@ +# ============================================================================ +# Aggregator Latency Monitor with Grafana Dashboard +# ============================================================================ + +BINARY_NAME = latency_monitor +BINARY_PATH = bin/monitor +GO_FILES = ./cmd/script + +.PHONY: help +help: + @echo "Aggregator Latency Monitor - Grafana Dashboard" + @echo "==============================================" + @echo "" + @echo "Commands:" + @echo " make run - Start everything (Grafana + All monitors in background)" + @echo " make pulse - Start Mobula Pulse monitor only (foreground)" + @echo " make stop - Stop all services" + @echo " make logs - Follow monitor logs" + @echo " make status - Show status of all services" + @echo " make build - Build Go binary" + @echo " make clean - Stop services and remove binaries/logs" + @echo " make destroy - Remove everything including volumes (asks confirmation)" + @echo "" + @echo "Dashboard Access:" + @echo " Grafana: http://localhost:3000 (admin/admin)" + @echo " Prometheus: http://localhost:9090" + @echo " Metrics: http://localhost:2112/metrics" + @echo "" + +.PHONY: deps +deps: + @echo "📦 Downloading dependencies..." + @go mod tidy + @go mod download + @echo "✓ Dependencies ready" + @echo "" + +.PHONY: build +build: deps + @echo "🔨 Building $(BINARY_NAME)..." + @mkdir -p bin + @go build -o $(BINARY_PATH) $(GO_FILES) + @echo "✓ Build complete: $(BINARY_PATH)" + @echo "" + +.PHONY: start-grafana +start-grafana: + @echo "📊 Starting Grafana + Prometheus stack..." + @docker-compose up -d + @echo "✓ Grafana stack running" + @echo " → Grafana: http://localhost:3000 (admin/admin)" + @echo " → Prometheus: http://localhost:9090" + @echo "" + +.PHONY: run +run: build start-grafana + @echo "🚀 Starting Aggregator Latency Monitors in background..." + @echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + @echo " → Starting monitors (CoinGecko, Mobula Pulse, Mobula Trade, Codex)..." + @./$(BINARY_PATH) > monitor.log 2>&1 & echo $$! > monitor.pid + @sleep 2 + @if [ -f monitor.pid ]; then \ + echo " ✓ Monitors started (PID: $$(cat monitor.pid))"; \ + else \ + echo " ❌ Failed to start monitors"; \ + fi + @echo "" + @echo "✓ All monitors running" + @echo "✓ Monitoring: CoinGecko, Mobula (Pulse + Trade), Codex" + @echo "✓ Chains: Solana, BNB, Base, Monad" + @echo "✓ Metrics: http://localhost:2112/metrics" + @echo "✓ Logs: make logs" + @echo "✓ Stop: make stop" + @echo "" + +.PHONY: down +down: + @echo "🛑 Stopping services..." + @if [ -f monitor.pid ]; then \ + echo " → Stopping monitors (PID: $$(cat monitor.pid))..."; \ + kill $$(cat monitor.pid) 2>/dev/null || true; \ + rm -f monitor.pid; \ + fi + @echo " → Killing all monitor processes..." + @pkill -9 -f "bin/monitor" 2>/dev/null || true + @echo " → Stopping Docker containers..." + @docker-compose down 2>/dev/null || true + @docker stop prometheus grafana 2>/dev/null || true + @docker rm prometheus grafana 2>/dev/null || true + @echo "✓ All services stopped (volumes preserved)" + +.PHONY: stop +stop: down + +.PHONY: clean +clean: down + @echo "🧹 Cleaning binaries and logs..." + @rm -f $(BINARY_PATH) $(BINARY_NAME) monitor.log + @echo "✓ Clean complete" + +.PHONY: logs +logs: + @if [ -f monitor.log ]; then \ + tail -f monitor.log; \ + else \ + echo "❌ No log file found. Is the monitor running?"; \ + echo " Run 'make run' first"; \ + fi + +.PHONY: status +status: + @echo "📊 Service Status:" + @echo "" + @if [ -f monitor.pid ] && kill -0 $$(cat monitor.pid) 2>/dev/null; then \ + echo " ✓ Monitors: Running (PID: $$(cat monitor.pid))"; \ + else \ + echo " ✗ Monitors: Stopped"; \ + fi + @if docker-compose ps | grep -q "Up"; then \ + echo " ✓ Grafana: Running (http://localhost:3000)"; \ + echo " ✓ Prometheus: Running (http://localhost:9090)"; \ + else \ + echo " ✗ Grafana: Stopped"; \ + echo " ✗ Prometheus: Stopped"; \ + fi + @echo "" + +.PHONY: destroy +destroy: + @echo "⚠️ WARNING: This will remove all containers, volumes, and binaries!" + @echo "⚠️ All Grafana dashboards and Prometheus data will be lost!" + @echo "" + @read -p "Are you sure? [y/N] " -n 1 -r; \ + echo; \ + if [[ $$REPLY =~ ^[Yy]$$ ]]; then \ + echo "🗑️ Destroying everything..."; \ + if [ -f monitor.pid ]; then kill $$(cat monitor.pid) 2>/dev/null || true; rm -f monitor.pid; fi; \ + pkill -9 -f "bin/monitor" 2>/dev/null || true; \ + docker-compose down -v 2>/dev/null || true; \ + rm -f $(BINARY_PATH) $(BINARY_NAME) monitor.log monitor.pid; \ + echo "✓ Everything destroyed"; \ + else \ + echo "❌ Cancelled"; \ + fi + +.PHONY: pulse +pulse: + @echo "🚀 Starting Mobula Pulse V2 Monitor..." + @go run ./cmd/pulse/*.go + +.DEFAULT_GOAL := help diff --git a/harnesses/aggregator-head-lag/alertmanager/Dockerfile b/harnesses/aggregator-head-lag/alertmanager/Dockerfile new file mode 100644 index 00000000..2c74ae71 --- /dev/null +++ b/harnesses/aggregator-head-lag/alertmanager/Dockerfile @@ -0,0 +1,12 @@ +FROM prom/alertmanager:latest + +# Copy AlertManager configuration +COPY alertmanager.yml /etc/alertmanager/alertmanager.yml + +# Expose AlertManager port +EXPOSE 9093 + +# Run AlertManager +CMD ["--config.file=/etc/alertmanager/alertmanager.yml", \ + "--storage.path=/alertmanager", \ + "--log.level=debug"] diff --git a/harnesses/aggregator-head-lag/alertmanager/alertmanager.yml b/harnesses/aggregator-head-lag/alertmanager/alertmanager.yml new file mode 100644 index 00000000..6ea7ebaa --- /dev/null +++ b/harnesses/aggregator-head-lag/alertmanager/alertmanager.yml @@ -0,0 +1,36 @@ +global: + resolve_timeout: 5m + +route: + group_by: ['alertname', 'chain', 'aggregator'] + group_wait: 10s + group_interval: 30s + repeat_interval: 5m + receiver: 'slack-webhook' + +receivers: + - name: 'slack-webhook' + webhook_configs: + - url: 'https://agent-slack-production.up.railway.app/webhook/grafana' + send_resolved: true + +inhibit_rules: + # Inhibit warning alerts if critical alert is firing + - source_match: + severity: 'critical' + target_match: + severity: 'warning' + equal: ['alertname', 'chain', 'aggregator'] + + # Inhibit stale metrics alerts if service is down + - source_match: + alert_type: 'service_down' + target_match: + alert_type: 'stale_metrics' + + # Inhibit stale metrics if missing metrics alert is firing + - source_match: + alert_type: 'missing_metrics' + target_match: + alert_type: 'stale_metrics' + equal: ['aggregator'] diff --git a/harnesses/aggregator-head-lag/assets/logo.png b/harnesses/aggregator-head-lag/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..e40972bdec57aac376b0d99672528992e35aaf19 GIT binary patch literal 65169 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 z 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 literal 0 HcmV?d00001 diff --git a/harnesses/aggregator-head-lag/auto_clean_spikes.sh b/harnesses/aggregator-head-lag/auto_clean_spikes.sh new file mode 100755 index 00000000..50946f34 --- /dev/null +++ b/harnesses/aggregator-head-lag/auto_clean_spikes.sh @@ -0,0 +1,134 @@ +#!/bin/bash + +PROM_URL="https://prometheus-production-0859.up.railway.app" +THRESHOLD=${1:-5} +HOURS=${2:-24} +DRY_RUN=${3:-false} + +echo "=== AUTO CLEAN SPIKES > ${THRESHOLD}s (last ${HOURS}h) ===" +echo "" + +# Fetch spikes +END_TS=$(date +%s) +START_TS=$((END_TS - HOURS * 3600)) + +QUERY='head_lag_seconds{aggregator="mobula"}' +ENCODED_QUERY=$(printf %s "$QUERY" | jq -sRr @uri) + +echo "1. Fetching spikes..." +curl -s "${PROM_URL}/api/v1/query_range?query=${ENCODED_QUERY}&start=${START_TS}&end=${END_TS}&step=15" | \ +jq -r --argjson threshold "$THRESHOLD" ' + .data.result[]? | + .metric as $m | + (.values // [])[] | + (.[1] | tonumber) as $val | + select($val > $threshold) | + "\(.[0])|\($m.region)|\($m.chain)|\($val)" +' > /tmp/raw_spikes.txt + +TOTAL=$(wc -l < /tmp/raw_spikes.txt | tr -d ' ') +echo " Found $TOTAL spike points" +echo "" + +# Grouper les spikes consécutifs par région/chain +echo "2. Grouping consecutive spikes..." +GROUPS=0 + +cat /tmp/raw_spikes.txt | sort -t'|' -k2,2 -k3,3 -k1,1n | \ +awk -F'|' ' +BEGIN { + prev_region = ""; + prev_chain = ""; + prev_ts = 0; + start_ts = 0; + end_ts = 0; +} +{ + region = $2; + chain = $3; + ts = $1; + val = $4; + + # Nouvelle série ou gap > 2 minutes + if (region != prev_region || chain != prev_chain || (ts - prev_ts) > 120) { + # Print previous group + if (start_ts > 0) { + print prev_region "|" prev_chain "|" start_ts "|" end_ts; + } + # Start new group + start_ts = ts; + end_ts = ts; + } else { + # Extend current group + end_ts = ts; + } + + prev_region = region; + prev_chain = chain; + prev_ts = ts; +} +END { + # Print last group + if (start_ts > 0) { + print prev_region "|" prev_chain "|" start_ts "|" end_ts; + } +}' > /tmp/spike_groups.txt + +GROUPS=$(wc -l < /tmp/spike_groups.txt | tr -d ' ') +echo " Grouped into $GROUPS spike ranges" +echo "" + +# Afficher les groupes +echo "3. Spike ranges to delete:" +echo "" +cat /tmp/spike_groups.txt | while IFS='|' read -r region chain start_ts end_ts; do + START_DATE=$(date -r "$start_ts" '+%Y-%m-%d %H:%M:%S') + END_DATE=$(date -r "$end_ts" '+%Y-%m-%d %H:%M:%S') + DURATION=$((end_ts - start_ts)) + printf " [%-8s] mobula - %-8s | %s → %s (%ds)\n" "$region" "$chain" "$START_DATE" "$END_DATE" "$DURATION" +done + +echo "" + +if [ "$DRY_RUN" = "true" ]; then + echo "DRY RUN - No deletion performed" + echo "Run without 'true' parameter to actually delete" + exit 0 +fi + +echo "4. Deleting spikes (with ±180s margin)..." +echo "" + +DELETED=0 +cat /tmp/spike_groups.txt | while IFS='|' read -r region chain start_ts end_ts; do + # Add ±180s margin + EXPANDED_START=$((start_ts - 180)) + EXPANDED_END=$((end_ts + 180)) + + MATCH="head_lag_seconds{aggregator=\"mobula\",region=\"${region}\",chain=\"${chain}\"}" + + START_DATE=$(date -r "$start_ts" '+%Y-%m-%d %H:%M:%S') + END_DATE=$(date -r "$end_ts" '+%Y-%m-%d %H:%M:%S') + + printf " Deleting [%-8s] mobula - %-8s | %s → %s ... " "$region" "$chain" "$START_DATE" "$END_DATE" + + STATUS=$(curl -s -X POST "${PROM_URL}/api/v1/admin/tsdb/delete_series?match[]=$(printf %s "$MATCH" | jq -sRr @uri)&start=${EXPANDED_START}&end=${EXPANDED_END}" -w "%{http_code}") + + if [ "$STATUS" = "204" ]; then + echo "✓" + DELETED=$((DELETED + 1)) + else + echo "✗ (status: $STATUS)" + fi +done + +echo "" +echo "5. Cleaning tombstones..." +CLEAN_STATUS=$(curl -s -X POST "${PROM_URL}/api/v1/admin/tsdb/clean_tombstones" -w "%{http_code}") +echo " Status: $CLEAN_STATUS" + +echo "" +echo "=== DONE ===" +echo "Deleted $GROUPS spike range(s)" +echo "" +echo "Usage: $0 [threshold] [hours] [dry-run-true/false]" diff --git a/harnesses/aggregator-head-lag/cleanup-prometheus.sh b/harnesses/aggregator-head-lag/cleanup-prometheus.sh new file mode 100755 index 00000000..4ee6f953 --- /dev/null +++ b/harnesses/aggregator-head-lag/cleanup-prometheus.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# Clean Prometheus data older than 1 day + +PROMETHEUS_URL="https://prometheus-production-0859.up.railway.app" + +# Calculate timestamp for 1 day ago (in milliseconds) +ONE_DAY_AGO=$(($(date +%s) - 86400)) +ONE_DAY_AGO_MS=$((ONE_DAY_AGO * 1000)) + +echo "Deleting Prometheus data older than $(date -r $ONE_DAY_AGO)" +echo "Keeping data from: $(date -r $ONE_DAY_AGO) to now" + +# Delete all series older than 1 day +curl -X POST \ + "${PROMETHEUS_URL}/api/v1/admin/tsdb/delete_series" \ + -d 'match[]={__name__=~".+"}' \ + -d "start=0" \ + -d "end=${ONE_DAY_AGO_MS}" + +echo "" +echo "Triggering cleanup (this removes tombstones)..." + +# Trigger cleanup to reclaim disk space +curl -X POST "${PROMETHEUS_URL}/api/v1/admin/tsdb/clean_tombstones" + +echo "" +echo "Done! Prometheus now only retains last 1 day of data." diff --git a/harnesses/aggregator-head-lag/docker-compose.yml b/harnesses/aggregator-head-lag/docker-compose.yml new file mode 100644 index 00000000..7536daee --- /dev/null +++ b/harnesses/aggregator-head-lag/docker-compose.yml @@ -0,0 +1,82 @@ +services: + monitor: + build: + context: . + dockerfile: Dockerfile + container_name: latency_monitor + ports: + - "2112:2112" + environment: + - COINGECKO_API_KEY=${COINGECKO_API_KEY} + - MOBULA_API_KEY=${MOBULA_API_KEY} + - DEFINED_SESSION_COOKIE=${DEFINED_SESSION_COOKIE} + networks: + - monitoring + restart: unless-stopped + + prometheus: + image: prom/prometheus:latest + container_name: prometheus + ports: + - "9090:9090" + volumes: + - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml + - ./monitoring/alert_rules.yml:/etc/prometheus/alert_rules.yml + - prometheus_data:/prometheus + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + - '--web.console.libraries=/etc/prometheus/console_libraries' + - '--web.console.templates=/etc/prometheus/consoles' + - '--web.enable-lifecycle' + networks: + - monitoring + restart: unless-stopped + depends_on: + - monitor + + alertmanager: + image: prom/alertmanager:latest + container_name: alertmanager + ports: + - "9093:9093" + volumes: + - ./monitoring/alertmanager.yml:/etc/alertmanager/alertmanager.yml + command: + - '--config.file=/etc/alertmanager/alertmanager.yml' + - '--storage.path=/alertmanager' + networks: + - monitoring + restart: unless-stopped + + grafana: + image: grafana/grafana:latest + container_name: grafana + ports: + - "3000:3000" + volumes: + - ./monitoring/grafana/provisioning:/etc/grafana/provisioning + - ./monitoring/grafana/dashboards:/dashboards-source:ro + - ./grafana-entrypoint.sh:/grafana-entrypoint.sh:ro + entrypoint: ["/bin/sh", "/grafana-entrypoint.sh"] + environment: + - GF_SECURITY_ADMIN_USER=admin + - GF_SECURITY_ADMIN_PASSWORD=${GF_SECURITY_ADMIN_PASSWORD:-admin} + - GF_USERS_ALLOW_SIGN_UP=false + - GF_SERVER_ROOT_URL=${GF_SERVER_ROOT_URL:-http://localhost:3000} + - GF_INSTALL_PLUGINS=grafana-clock-panel + - GF_AUTH_ANONYMOUS_ENABLED=true + - GF_AUTH_ANONYMOUS_ORG_ROLE=Viewer + - GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH=/var/lib/grafana/dashboards/head_lag.json + networks: + - monitoring + depends_on: + - prometheus + restart: unless-stopped + +networks: + monitoring: + driver: bridge + +volumes: + prometheus_data: diff --git a/harnesses/aggregator-head-lag/grafana-entrypoint.sh b/harnesses/aggregator-head-lag/grafana-entrypoint.sh new file mode 100755 index 00000000..ccf63ca7 --- /dev/null +++ b/harnesses/aggregator-head-lag/grafana-entrypoint.sh @@ -0,0 +1,29 @@ +#!/bin/sh + +echo "=== GRAFANA ENTRYPOINT SCRIPT STARTING ===" +echo "Working directory: $(pwd)" +echo "User: $(whoami)" + +# Create dashboards directory +mkdir -p /var/lib/grafana/dashboards + +# Debug: print environment variables +echo "Checking environment..." +env | grep -E '(RAILWAY|HIDE_QUOTE)' || echo "No RAILWAY/HIDE_QUOTE variables found" + +# Copy dashboards from source +if [ "$HIDE_QUOTE_DASHBOARD" = "true" ]; then + echo "HIDE_QUOTE_DASHBOARD=true - hiding Quote API Latency Benchmark dashboard" + cp /dashboards-source/head_lag.json /var/lib/grafana/dashboards/ +else + echo "Copying all dashboards" + cp /dashboards-source/*.json /var/lib/grafana/dashboards/ +fi + +echo "Dashboards copied:" +ls -la /var/lib/grafana/dashboards/ + +echo "=== GRAFANA ENTRYPOINT SCRIPT COMPLETE ===" + +# Start Grafana with default entrypoint +exec /run.sh diff --git a/harnesses/aggregator-head-lag/grafana/Dockerfile b/harnesses/aggregator-head-lag/grafana/Dockerfile new file mode 100644 index 00000000..eb500db9 --- /dev/null +++ b/harnesses/aggregator-head-lag/grafana/Dockerfile @@ -0,0 +1,34 @@ +FROM grafana/grafana:latest + +USER root + +# Cache buster - update this to force rebuild: v7 +ARG CACHE_BUST=7 + +# Copy provisioning configs and dashboards source (we're in grafana folder) +COPY provisioning /etc/grafana/provisioning +COPY dashboards /dashboards-source + +# Copy entrypoint script +COPY grafana-entrypoint.sh /grafana-entrypoint.sh +RUN chmod +x /grafana-entrypoint.sh + +# Set permissions +RUN chown -R grafana:root /etc/grafana/provisioning /dashboards-source + +USER grafana + +# Environment variables +ENV GF_AUTH_ANONYMOUS_ENABLED=true +ENV GF_AUTH_ANONYMOUS_ORG_ROLE=Viewer +ENV GF_SECURITY_ADMIN_USER=admin +ENV GF_SECURITY_ADMIN_PASSWORD=admin +ENV GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH=/var/lib/grafana/dashboards/head_lag.json + +# Prometheus datasource URL (overridden by staging deployment) +ENV PROMETHEUS_URL=http://prometheus.railway.internal:9090 + +EXPOSE 3000 + +# Use custom entrypoint +ENTRYPOINT ["/bin/sh", "/grafana-entrypoint.sh"] diff --git a/harnesses/aggregator-head-lag/grafana/dashboards/disabled/mobula_pulse_vs_fasttrade.json b/harnesses/aggregator-head-lag/grafana/dashboards/disabled/mobula_pulse_vs_fasttrade.json new file mode 100644 index 00000000..373e2f06 --- /dev/null +++ b/harnesses/aggregator-head-lag/grafana/dashboards/disabled/mobula_pulse_vs_fasttrade.json @@ -0,0 +1,595 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Latency (ms)", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 2000 + }, + { + "color": "orange", + "value": 5000 + }, + { + "color": "red", + "value": 10000 + } + ] + }, + "unit": "ms" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*Pulse.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "blue", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*Fast-Trade.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "orange", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": ["mean", "last", "max", "min"], + "displayMode": "table", + "placement": "right", + "showLegend": true, + "sortBy": "Mean", + "sortDesc": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "pool_discovery_latency_milliseconds{aggregator=\"mobula\"}", + "legendFormat": "[{{region}}] Pulse V2 - {{chain}}", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "fast_trade_latency_milliseconds{aggregator=\"mobula\"}", + "legendFormat": "[{{region}}] Fast-Trade - {{chain}}", + "range": true, + "refId": "B" + } + ], + "title": "Mobula: Pulse V2 (Discovery) vs Fast-Trade (Swap Indexation)", + "description": "Pulse V2 measures pool discovery latency (on-chain creation → Mobula indexation). Fast-Trade measures swap indexation latency (on-chain swap → WebSocket receipt).", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 2000 + }, + { + "color": "orange", + "value": 5000 + }, + { + "color": "red", + "value": 10000 + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 10 + }, + "id": 3, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": ["last"], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "fast_trade_latency_milliseconds{aggregator=\"mobula\"}", + "legendFormat": "[{{region}}] {{chain}}", + "range": true, + "refId": "A" + } + ], + "title": "Fast-Trade - Current Swap Indexation Latency", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Latency (ms)", + "axisPlacement": "auto", + "drawStyle": "line", + "fillOpacity": 10, + "lineInterpolation": "linear", + "lineWidth": 2, + "showPoints": "auto" + }, + "unit": "ms", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 2000 + }, + { + "color": "red", + "value": 5000 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 10 + }, + "id": 4, + "options": { + "legend": { + "calcs": ["last"], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "pool_discovery_latency_milliseconds{aggregator=\"mobula\",chain=\"solana\"}", + "legendFormat": "[{{region}}] Pulse V2", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "fast_trade_latency_milliseconds{aggregator=\"mobula\",chain=\"solana\"}", + "legendFormat": "[{{region}}] Fast-Trade", + "refId": "B" + } + ], + "title": "Solana - Pulse V2 vs Fast-Trade", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Latency (ms)", + "axisPlacement": "auto", + "drawStyle": "line", + "fillOpacity": 10, + "lineInterpolation": "linear", + "lineWidth": 2, + "showPoints": "auto" + }, + "unit": "ms", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 2000 + }, + { + "color": "red", + "value": 5000 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 10 + }, + "id": 5, + "options": { + "legend": { + "calcs": ["last"], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "pool_discovery_latency_milliseconds{aggregator=\"mobula\",chain=\"base\"}", + "legendFormat": "[{{region}}] Pulse V2", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "fast_trade_latency_milliseconds{aggregator=\"mobula\",chain=\"base\"}", + "legendFormat": "[{{region}}] Fast-Trade", + "refId": "B" + } + ], + "title": "Base - Pulse V2 vs Fast-Trade", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Latency (ms)", + "axisPlacement": "auto", + "drawStyle": "line", + "fillOpacity": 10, + "lineInterpolation": "linear", + "lineWidth": 2, + "showPoints": "auto" + }, + "unit": "ms", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 2000 + }, + { + "color": "red", + "value": 5000 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 10 + }, + "id": 6, + "options": { + "legend": { + "calcs": ["last"], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "pool_discovery_latency_milliseconds{aggregator=\"mobula\",chain=\"ethereum\"}", + "legendFormat": "[{{region}}] Pulse V2", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "fast_trade_latency_milliseconds{aggregator=\"mobula\",chain=\"ethereum\"}", + "legendFormat": "[{{region}}] Fast-Trade", + "refId": "B" + } + ], + "title": "Ethereum - Pulse V2 vs Fast-Trade", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Latency (ms)", + "axisPlacement": "auto", + "drawStyle": "line", + "fillOpacity": 10, + "lineInterpolation": "linear", + "lineWidth": 2, + "showPoints": "auto" + }, + "unit": "ms", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 2000 + }, + { + "color": "red", + "value": 5000 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 18 + }, + "id": 7, + "options": { + "legend": { + "calcs": ["last"], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "pool_discovery_latency_milliseconds{aggregator=\"mobula\",chain=\"bnb\"}", + "legendFormat": "[{{region}}] Pulse V2", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "fast_trade_latency_milliseconds{aggregator=\"mobula\",chain=\"bnb\"}", + "legendFormat": "[{{region}}] Fast-Trade", + "refId": "B" + } + ], + "title": "BNB - Pulse V2 vs Fast-Trade", + "type": "timeseries" + } + ], + "refresh": "30s", + "schemaVersion": 39, + "tags": ["mobula", "pulse", "fast-trade", "comparison"], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Mobula: Pulse V2 vs Fast-Trade Comparison", + "uid": "mobula_pulse_vs_fasttrade", + "version": 0, + "weekStart": "" +} diff --git a/harnesses/aggregator-head-lag/grafana/dashboards/disabled/pulse_vs_codex.json b/harnesses/aggregator-head-lag/grafana/dashboards/disabled/pulse_vs_codex.json new file mode 100644 index 00000000..8ced6186 --- /dev/null +++ b/harnesses/aggregator-head-lag/grafana/dashboards/disabled/pulse_vs_codex.json @@ -0,0 +1,661 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Direct comparison of Pulse vs Codex head lag on monitored pools", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Latency (seconds)", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 5 + } + ] + }, + "unit": "s" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*pulse.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "blue", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*codex.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": ["lastNotNull", "mean", "max"], + "displayMode": "table", + "placement": "right", + "showLegend": true, + "sortBy": "Mean", + "sortDesc": false + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "head_lag_seconds{aggregator=~\"pulse|codex\",chain=~\"$chain\"}", + "legendFormat": "{{chain}} | {{aggregator}}", + "range": true, + "refId": "A" + } + ], + "title": "Pulse vs Codex - Head Lag Comparison", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Shows which provider is faster (negative = Pulse faster, positive = Codex faster)", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": true, + "axisColorMode": "text", + "axisLabel": "Delta (seconds)", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 30, + "gradientMode": "hue", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "area" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": -1 + }, + { + "color": "red", + "value": 1 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 24, + "x": 0, + "y": 10 + }, + "id": 2, + "options": { + "legend": { + "calcs": ["mean", "lastNotNull"], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "(head_lag_seconds{aggregator=\"pulse\",chain=~\"$chain\"} - ignoring(aggregator) head_lag_seconds{aggregator=\"codex\",chain=~\"$chain\"})", + "legendFormat": "{{chain}} - Pulse vs Codex delta", + "range": true, + "refId": "A" + } + ], + "title": "Latency Delta: Pulse - Codex (negative = Pulse faster)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "color-text" + }, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 1 + }, + { + "color": "orange", + "value": 2 + }, + { + "color": "red", + "value": 5 + } + ] + }, + "unit": "s" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Chain" + }, + "properties": [ + { + "id": "custom.width", + "value": 120 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Provider" + }, + "properties": [ + { + "id": "custom.width", + "value": 100 + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 19 + }, + "id": 3, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": ["sum"], + "show": false + }, + "showHeader": true, + "sortBy": [ + { + "desc": false, + "displayName": "Mean (5m)" + } + ] + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "exemplar": false, + "expr": "head_lag_seconds{aggregator=~\"pulse|codex\",chain=~\"$chain\"}", + "format": "table", + "instant": true, + "legendFormat": "__auto", + "range": false, + "refId": "Current" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "exemplar": false, + "expr": "avg_over_time(head_lag_seconds{aggregator=~\"pulse|codex\",chain=~\"$chain\"}[5m])", + "format": "table", + "hide": false, + "instant": true, + "legendFormat": "__auto", + "range": false, + "refId": "Mean5m" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "exemplar": false, + "expr": "max_over_time(head_lag_seconds{aggregator=~\"pulse|codex\",chain=~\"$chain\"}[5m])", + "format": "table", + "hide": false, + "instant": true, + "legendFormat": "__auto", + "range": false, + "refId": "Max5m" + } + ], + "title": "Current Stats - Pulse vs Codex", + "transformations": [ + { + "id": "merge", + "options": {} + }, + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true, + "__name__": true, + "deployment": true, + "instance": true, + "job": true, + "region": true + }, + "includeByName": {}, + "indexByName": {}, + "renameByName": { + "aggregator": "Provider", + "chain": "Chain" + } + } + }, + { + "id": "groupBy", + "options": { + "fields": { + "Chain": { + "aggregations": [], + "operation": "groupby" + }, + "Provider": { + "aggregations": [], + "operation": "groupby" + }, + "Value #Current": { + "aggregations": ["lastNotNull"], + "operation": "aggregate" + }, + "Value #Max5m": { + "aggregations": ["lastNotNull"], + "operation": "aggregate" + }, + "Value #Mean5m": { + "aggregations": ["lastNotNull"], + "operation": "aggregate" + } + } + } + }, + { + "id": "organize", + "options": { + "excludeByName": {}, + "includeByName": {}, + "indexByName": { + "Chain": 0, + "Provider": 1, + "Value #Current (lastNotNull)": 2, + "Value #Max5m (lastNotNull)": 4, + "Value #Mean5m (lastNotNull)": 3 + }, + "renameByName": { + "Value #Current (lastNotNull)": "Current", + "Value #Max5m (lastNotNull)": "Max (5m)", + "Value #Mean5m (lastNotNull)": "Mean (5m)" + } + } + } + ], + "type": "table" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 1 + }, + { + "color": "red", + "value": 3 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 27 + }, + "id": 4, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "head_lag_seconds{aggregator=\"pulse\",chain=~\"$chain\"}", + "instant": true, + "legendFormat": "{{chain}}", + "refId": "A" + } + ], + "title": "Pulse - Current Head Lag", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 1 + }, + { + "color": "red", + "value": 3 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 27 + }, + "id": 5, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "head_lag_seconds{aggregator=\"codex\",chain=~\"$chain\"}", + "instant": true, + "legendFormat": "{{chain}}", + "refId": "A" + } + ], + "title": "Codex - Current Head Lag", + "type": "gauge" + } + ], + "refresh": "30s", + "schemaVersion": 39, + "tags": ["pulse", "codex", "comparison", "head-lag"], + "templating": { + "list": [ + { + "current": { + "selected": true, + "text": ["All"], + "value": ["$__all"] + }, + "hide": 0, + "includeAll": true, + "label": "Chain", + "multi": true, + "name": "chain", + "options": [ + { + "selected": true, + "text": "All", + "value": "$__all" + }, + { + "selected": false, + "text": "solana", + "value": "solana" + }, + { + "selected": false, + "text": "base", + "value": "base" + }, + { + "selected": false, + "text": "bnb", + "value": "bnb" + } + ], + "query": "solana,base,bnb", + "queryValue": "", + "skipUrlSync": false, + "type": "custom" + } + ] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Pulse vs Codex - Head Lag Comparison", + "uid": "pulse_vs_codex", + "version": 1, + "weekStart": "" +} diff --git a/harnesses/aggregator-head-lag/grafana/dashboards/disabled/quote_api_latency.json b/harnesses/aggregator-head-lag/grafana/dashboards/disabled/quote_api_latency.json new file mode 100644 index 00000000..d0e12537 --- /dev/null +++ b/harnesses/aggregator-head-lag/grafana/dashboards/disabled/quote_api_latency.json @@ -0,0 +1,916 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 3, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 500 + }, + { + "color": "red", + "value": 2000 + } + ] + }, + "unit": "ms" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "orange", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*jupiter.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*kyberswap.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "blue", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*lifi.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "purple", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*paraswap.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "yellow", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*openocean.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": ["mean", "lastNotNull", "max", "min"], + "displayMode": "table", + "placement": "right", + "showLegend": true, + "sortBy": "Mean", + "sortDesc": false + }, + "tooltip": { + "mode": "multi", + "sort": "asc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.50, sum(rate(quote_api_latency_milliseconds_bucket{chain!=\"arbitrum\"}[1m])) by (le, provider, chain))", + "legendFormat": "{{provider}} - {{chain}} (P50)", + "range": true, + "refId": "A" + } + ], + "title": "Quote API Latency Comparison - All Providers (P50)", + "description": "Median Quote API response time - Mobula vs competitors (30s polling interval)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 300 + }, + { + "color": "orange", + "value": 700 + }, + { + "color": "red", + "value": 1500 + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 24, + "x": 0, + "y": 10 + }, + "id": 2, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.50, sum(rate(quote_api_latency_milliseconds_bucket[5m])) by (le, provider))", + "legendFormat": "{{provider}}", + "range": true, + "refId": "A" + } + ], + "title": "Latest Quote API Latency (P50) by Provider", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 3, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 1000 + } + ] + }, + "unit": "ms" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "orange", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*jupiter.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 3, + "options": { + "legend": { + "calcs": ["mean", "lastNotNull", "max"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "asc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.50, sum(rate(quote_api_latency_milliseconds_bucket{chain=\"solana\"}[1m])) by (le, provider))", + "legendFormat": "{{provider}} (P50)", + "range": true, + "refId": "A" + } + ], + "title": "🌞 Solana Quote APIs - Mobula vs Jupiter", + "description": "Solana swap quote latency comparison", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 3, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 1000 + } + ] + }, + "unit": "ms" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "orange", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 16 + }, + "id": 4, + "options": { + "legend": { + "calcs": ["mean", "lastNotNull", "max"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "asc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.50, sum(rate(quote_api_latency_milliseconds_bucket{chain=\"base\"}[1m])) by (le, provider))", + "legendFormat": "{{provider}} (P50)", + "range": true, + "refId": "A" + } + ], + "title": "🔵 Base Quote APIs - Mobula vs Competitors", + "description": "Base chain swap quote latency comparison", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 3, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 1000 + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 24 + }, + "id": 6, + "options": { + "legend": { + "calcs": ["mean", "lastNotNull", "max"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "asc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.50, sum(rate(quote_api_latency_milliseconds_bucket{chain=\"ethereum\"}[1m])) by (le, provider))", + "legendFormat": "{{provider}} (P50)", + "range": true, + "refId": "A" + } + ], + "title": "⟠ Ethereum Quote APIs - Competitors Only", + "description": "Ethereum chain swap quote latency (Mobula not deployed)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "bars", + "fillOpacity": 80, + "gradientMode": "hue", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 12, + "x": 0, + "y": 32 + }, + "id": 7, + "options": { + "legend": { + "calcs": ["sum"], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(increase(quote_api_errors_total{chain!=\"arbitrum\"}[5m])) by (provider, chain)", + "legendFormat": "{{provider}} - {{chain}}", + "range": true, + "refId": "A" + } + ], + "title": "Quote API Errors (Last 5min)", + "description": "Number of errors per provider and chain", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 95 + }, + { + "color": "red", + "value": 99 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 12, + "x": 12, + "y": 32 + }, + "id": 8, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "100 * sum(rate(quote_api_status_codes_total{status_code=\"200\"}[5m])) by (provider) / sum(rate(quote_api_status_codes_total[5m])) by (provider)", + "legendFormat": "{{provider}}", + "range": true, + "refId": "A" + } + ], + "title": "Quote API Success Rate by Provider", + "description": "Percentage of successful (200) responses", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 38 + }, + "id": 9, + "options": { + "legend": { + "calcs": ["mean", "lastNotNull", "max"], + "displayMode": "table", + "placement": "right", + "showLegend": true, + "sortBy": "Mean", + "sortDesc": false + }, + "tooltip": { + "mode": "multi", + "sort": "asc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.50, sum(rate(quote_api_latency_milliseconds_bucket{provider=\"mobula\",chain!=\"arbitrum\"}[1m])) by (le, chain))", + "legendFormat": "Mobula - {{chain}} (P50)", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.95, sum(rate(quote_api_latency_milliseconds_bucket{provider=\"mobula\",chain!=\"arbitrum\"}[1m])) by (le, chain))", + "legendFormat": "Mobula - {{chain}} (P95)", + "range": true, + "refId": "B" + } + ], + "title": "Mobula Quote API Latency by Chain (P50 & P95)", + "description": "Mobula swap quote latency across supported chains", + "type": "timeseries" + } + ], + "refresh": "5s", + "schemaVersion": 39, + "tags": ["quote-api", "swap", "latency", "mobula", "jupiter", "benchmark"], + "templating": { + "list": [] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Quote API Latency Benchmark", + "uid": "quote_api_latency", + "version": 0, + "weekStart": "" +} diff --git a/harnesses/aggregator-head-lag/grafana/dashboards/head_lag.json b/harnesses/aggregator-head-lag/grafana/dashboards/head_lag.json new file mode 100644 index 00000000..c325ba7c --- /dev/null +++ b/harnesses/aggregator-head-lag/grafana/dashboards/head_lag.json @@ -0,0 +1,945 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Seconds Behind", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 30 + }, + { + "color": "orange", + "value": 60 + }, + { + "color": "red", + "value": 300 + } + ] + }, + "unit": "s" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#1F78B4", + "mode": "fixed" + } + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "#1F78B4", + "value": null + } + ] + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*codex.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#F4D03F", + "mode": "fixed" + } + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "#F4D03F", + "value": null + } + ] + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*geckoterminal.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#27AE60", + "mode": "fixed" + } + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "#27AE60", + "value": null + } + ] + } + } + ] + } + ] + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "calcs": [ + "mean", + "last", + "max", + "min" + ], + "displayMode": "table", + "placement": "right", + "showLegend": true, + "sortBy": "Mean", + "sortDesc": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "avg_over_time(head_lag_seconds[1m])", + "legendFormat": "[{{region}}] {{aggregator}} - {{chain}}", + "range": true, + "refId": "A" + } + ], + "title": "Head Lag (Estimated Seconds Behind)", + "description": "Estimated time in seconds the aggregator is behind the blockchain head. Calculated using average block time per chain.", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "fixed", + "fixedColor": "#1F78B4" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 5 + }, + { + "color": "orange", + "value": 10 + }, + { + "color": "red", + "value": 30 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 18 + }, + "id": 3, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "avg_over_time(head_lag_seconds{aggregator=\"mobula\"}[1m])", + "legendFormat": "[{{region}}] {{chain}}", + "range": true, + "refId": "A" + } + ], + "title": "Mobula - Current Head Lag (Seconds)", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "fixed", + "fixedColor": "#F4D03F" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 5 + }, + { + "color": "orange", + "value": 10 + }, + { + "color": "red", + "value": 30 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 18 + }, + "id": 4, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "avg_over_time(head_lag_seconds{aggregator=\"codex\"}[1m])", + "legendFormat": "[{{region}}] {{chain}}", + "range": true, + "refId": "A" + } + ], + "title": "Codex - Current Head Lag (Seconds)", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "fixed", + "fixedColor": "#27AE60" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 5 + }, + { + "color": "orange", + "value": 10 + }, + { + "color": "red", + "value": 30 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 18 + }, + "id": 5, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "avg_over_time(head_lag_seconds{aggregator=\"geckoterminal\"}[1m])", + "legendFormat": "[{{region}}] {{chain}}", + "range": true, + "refId": "A" + } + ], + "title": "GeckoTerminal - Current Head Lag (Seconds)", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Seconds", + "axisPlacement": "auto", + "drawStyle": "line", + "fillOpacity": 0, + "lineInterpolation": "linear", + "lineWidth": 2, + "showPoints": "auto", + "spanNulls": true + }, + "unit": "s", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 5 + }, + { + "color": "red", + "value": 20 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#1F78B4", + "mode": "fixed" + } + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "#1F78B4", + "value": null + } + ] + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*codex.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#F4D03F", + "mode": "fixed" + } + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "#F4D03F", + "value": null + } + ] + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*geckoterminal.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#27AE60", + "mode": "fixed" + } + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "#27AE60", + "value": null + } + ] + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 10 + }, + "id": 6, + "options": { + "legend": { + "calcs": [ + "lastNotNull" + ], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "avg_over_time(head_lag_seconds{region=\"eu-west\"}[1m])", + "legendFormat": "[{{region}}] {{aggregator}} - {{chain}}", + "refId": "A" + } + ], + "title": "EU West - Head Lag", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Seconds", + "axisPlacement": "auto", + "drawStyle": "line", + "fillOpacity": 0, + "lineInterpolation": "linear", + "lineWidth": 2, + "showPoints": "auto", + "spanNulls": true + }, + "unit": "s", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 5 + }, + { + "color": "red", + "value": 20 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#1F78B4", + "mode": "fixed" + } + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "#1F78B4", + "value": null + } + ] + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*codex.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#F4D03F", + "mode": "fixed" + } + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "#F4D03F", + "value": null + } + ] + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*geckoterminal.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#27AE60", + "mode": "fixed" + } + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "#27AE60", + "value": null + } + ] + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 10 + }, + "id": 7, + "options": { + "legend": { + "calcs": [ + "lastNotNull" + ], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "avg_over_time(head_lag_seconds{region=\"us-east\"}[1m])", + "legendFormat": "[{{region}}] {{aggregator}} - {{chain}}", + "refId": "A" + } + ], + "title": "US East - Head Lag", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Seconds", + "axisPlacement": "auto", + "drawStyle": "line", + "fillOpacity": 0, + "lineInterpolation": "linear", + "lineWidth": 2, + "showPoints": "auto", + "spanNulls": true + }, + "unit": "s", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 5 + }, + { + "color": "red", + "value": 20 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#1F78B4", + "mode": "fixed" + } + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "#1F78B4", + "value": null + } + ] + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*codex.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#F4D03F", + "mode": "fixed" + } + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "#F4D03F", + "value": null + } + ] + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*geckoterminal.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#27AE60", + "mode": "fixed" + } + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "#27AE60", + "value": null + } + ] + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 10 + }, + "id": 8, + "options": { + "legend": { + "calcs": [ + "lastNotNull" + ], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "avg_over_time(head_lag_seconds{region=\"sgp\"}[1m])", + "legendFormat": "[{{region}}] {{aggregator}} - {{chain}}", + "refId": "A" + } + ], + "title": "Singapore - Head Lag", + "type": "timeseries" + } + ], + "refresh": "30s", + "schemaVersion": 39, + "tags": [ + "head-lag", + "indexation", + "blockchain", + "sync" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Head Lag Monitor - Blockchain vs Aggregator Sync", + "uid": "head_lag_monitor", + "version": 0, + "weekStart": "" +} \ No newline at end of file diff --git a/harnesses/aggregator-head-lag/grafana/dashboards/metadata_coverage.json b/harnesses/aggregator-head-lag/grafana/dashboards/metadata_coverage.json new file mode 100644 index 00000000..b4c7b305 --- /dev/null +++ b/harnesses/aggregator-head-lag/grafana/dashboards/metadata_coverage.json @@ -0,0 +1,1133 @@ +{ + "annotations": { + "list": [] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "percent" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#1F78B4", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*codex.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#F4D03F", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*jupiter.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#27AE60", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "barRadius": 0.1, + "barWidth": 0.8, + "fullHighlight": false, + "groupWidth": 0.7, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "right", + "showLegend": true + }, + "orientation": "horizontal", + "showValue": "always", + "stacking": "none", + "tooltip": { + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 0 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "exemplar": false, + "expr": "(sum(metadata_coverage_success_total{field=\"logo\"}) by (provider) / sum(metadata_coverage_checks_total{field=\"logo\"}) by (provider)) * 100", + "format": "time_series", + "instant": true, + "legendFormat": "{{provider}} - Logo", + "range": false, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "exemplar": false, + "expr": "(sum(metadata_coverage_success_total{field=\"description\"}) by (provider) / sum(metadata_coverage_checks_total{field=\"description\"}) by (provider)) * 100", + "format": "time_series", + "instant": true, + "legendFormat": "{{provider}} - Description", + "range": false, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "exemplar": false, + "expr": "(sum(metadata_coverage_success_total{field=\"twitter\"}) by (provider) / sum(metadata_coverage_checks_total{field=\"twitter\"}) by (provider)) * 100", + "format": "time_series", + "instant": true, + "legendFormat": "{{provider}} - Twitter", + "range": false, + "refId": "C" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "exemplar": false, + "expr": "(sum(metadata_coverage_success_total{field=\"website\"}) by (provider) / sum(metadata_coverage_checks_total{field=\"website\"}) by (provider)) * 100", + "format": "time_series", + "instant": true, + "legendFormat": "{{provider}} - Website", + "range": false, + "refId": "D" + } + ], + "title": "Metadata Coverage Comparison: Mobula vs Codex vs Jupiter (%)", + "description": "Percentage of new tokens with each metadata field present", + "type": "barchart" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "yellow", + "value": 30 + }, + { + "color": "green", + "value": 70 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 4, + "x": 0, + "y": 8 + }, + "id": 2, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "(sum(metadata_coverage_success_total{provider=\"mobula\", field=\"logo\"}) / sum(metadata_coverage_checks_total{provider=\"mobula\", field=\"logo\"})) * 100", + "instant": true, + "legendFormat": "Logo", + "refId": "A" + } + ], + "title": "Mobula - Logo %", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "yellow", + "value": 30 + }, + { + "color": "green", + "value": 70 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 4, + "x": 4, + "y": 8 + }, + "id": 3, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "(sum(metadata_coverage_success_total{provider=\"codex\", field=\"logo\"}) / sum(metadata_coverage_checks_total{provider=\"codex\", field=\"logo\"})) * 100", + "instant": true, + "legendFormat": "Logo", + "refId": "A" + } + ], + "title": "Codex - Logo %", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "yellow", + "value": 30 + }, + { + "color": "green", + "value": 70 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 4, + "x": 8, + "y": 8 + }, + "id": 11, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "(sum(metadata_coverage_success_total{provider=\"jupiter\", field=\"logo\"}) / sum(metadata_coverage_checks_total{provider=\"jupiter\", field=\"logo\"})) * 100", + "instant": true, + "legendFormat": "Logo", + "refId": "A" + } + ], + "title": "Jupiter - Logo % (Solana)", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "yellow", + "value": 30 + }, + { + "color": "green", + "value": 70 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 4, + "x": 12, + "y": 8 + }, + "id": 4, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "(sum(metadata_coverage_success_total{provider=\"mobula\", field=\"description\"}) / sum(metadata_coverage_checks_total{provider=\"mobula\", field=\"description\"})) * 100", + "instant": true, + "legendFormat": "Desc", + "refId": "A" + } + ], + "title": "Mobula - Description %", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "yellow", + "value": 30 + }, + { + "color": "green", + "value": 70 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 4, + "x": 16, + "y": 8 + }, + "id": 5, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "(sum(metadata_coverage_success_total{provider=\"codex\", field=\"description\"}) / sum(metadata_coverage_checks_total{provider=\"codex\", field=\"description\"})) * 100", + "instant": true, + "legendFormat": "Desc", + "refId": "A" + } + ], + "title": "Codex - Description %", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "percent" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#1F78B4", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*codex.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#F4D03F", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*jupiter.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#27AE60", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 13 + }, + "id": 6, + "options": { + "legend": { + "calcs": [ + "mean", + "lastNotNull" + ], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "(sum(metadata_coverage_success_total{field=\"logo\"}) by (provider) / sum(metadata_coverage_checks_total{field=\"logo\"}) by (provider)) * 100", + "legendFormat": "{{provider}}", + "range": true, + "refId": "A" + } + ], + "title": "Logo Coverage Over Time", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "percent" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#1F78B4", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*codex.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#F4D03F", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*jupiter.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#27AE60", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 13 + }, + "id": 7, + "options": { + "legend": { + "calcs": [ + "mean", + "lastNotNull" + ], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "(sum(metadata_coverage_success_total{field=\"description\"}) by (provider) / sum(metadata_coverage_checks_total{field=\"description\"}) by (provider)) * 100", + "legendFormat": "{{provider}}", + "range": true, + "refId": "A" + } + ], + "title": "Description Coverage Over Time", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "ms" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#1F78B4", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*codex.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#F4D03F", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*jupiter.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#27AE60", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 21 + }, + "id": 8, + "options": { + "legend": { + "calcs": [ + "mean", + "lastNotNull", + "max" + ], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.50, sum(rate(metadata_api_latency_milliseconds_bucket[5m])) by (le, provider))", + "legendFormat": "{{provider}} (P50)", + "range": true, + "refId": "A" + } + ], + "title": "Metadata API Latency (P50)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 8, + "x": 0, + "y": 29 + }, + "id": 9, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(metadata_coverage_checks_total{field=\"logo\"}) by (provider)", + "instant": true, + "legendFormat": "{{provider}}", + "refId": "A" + } + ], + "title": "Total Tokens Checked by Provider", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 16, + "x": 8, + "y": 29 + }, + "id": 10, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(metadata_coverage_checks_total{field=\"logo\"}) by (chain)", + "instant": true, + "legendFormat": "{{chain}}", + "refId": "A" + } + ], + "title": "Tokens Checked by Chain", + "type": "stat" + } + ], + "refresh": "30s", + "schemaVersion": 39, + "tags": [ + "metadata", + "coverage", + "logo", + "benchmark", + "mobula", + "codex", + "jupiter" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Metadata Coverage Benchmark", + "uid": "metadata_coverage_benchmark", + "version": 0, + "weekStart": "" +} \ No newline at end of file diff --git a/harnesses/aggregator-head-lag/grafana/grafana-entrypoint.sh b/harnesses/aggregator-head-lag/grafana/grafana-entrypoint.sh new file mode 100755 index 00000000..ccf63ca7 --- /dev/null +++ b/harnesses/aggregator-head-lag/grafana/grafana-entrypoint.sh @@ -0,0 +1,29 @@ +#!/bin/sh + +echo "=== GRAFANA ENTRYPOINT SCRIPT STARTING ===" +echo "Working directory: $(pwd)" +echo "User: $(whoami)" + +# Create dashboards directory +mkdir -p /var/lib/grafana/dashboards + +# Debug: print environment variables +echo "Checking environment..." +env | grep -E '(RAILWAY|HIDE_QUOTE)' || echo "No RAILWAY/HIDE_QUOTE variables found" + +# Copy dashboards from source +if [ "$HIDE_QUOTE_DASHBOARD" = "true" ]; then + echo "HIDE_QUOTE_DASHBOARD=true - hiding Quote API Latency Benchmark dashboard" + cp /dashboards-source/head_lag.json /var/lib/grafana/dashboards/ +else + echo "Copying all dashboards" + cp /dashboards-source/*.json /var/lib/grafana/dashboards/ +fi + +echo "Dashboards copied:" +ls -la /var/lib/grafana/dashboards/ + +echo "=== GRAFANA ENTRYPOINT SCRIPT COMPLETE ===" + +# Start Grafana with default entrypoint +exec /run.sh diff --git a/harnesses/aggregator-head-lag/grafana/provisioning/dashboards/dashboard.yml b/harnesses/aggregator-head-lag/grafana/provisioning/dashboards/dashboard.yml new file mode 100644 index 00000000..332c8f34 --- /dev/null +++ b/harnesses/aggregator-head-lag/grafana/provisioning/dashboards/dashboard.yml @@ -0,0 +1,13 @@ +apiVersion: 1 + +providers: + - name: 'Aggregator Latency Dashboards' + orgId: 1 + folder: '' + type: file + disableDeletion: false + updateIntervalSeconds: 10 + allowUiUpdates: false + options: + path: /var/lib/grafana/dashboards + foldersFromFilesStructure: true diff --git a/harnesses/aggregator-head-lag/grafana/provisioning/datasources/prometheus.yml b/harnesses/aggregator-head-lag/grafana/provisioning/datasources/prometheus.yml new file mode 100644 index 00000000..30d5cc21 --- /dev/null +++ b/harnesses/aggregator-head-lag/grafana/provisioning/datasources/prometheus.yml @@ -0,0 +1,10 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: ${PROMETHEUS_URL} + uid: prometheus + isDefault: true + editable: false diff --git a/harnesses/aggregator-head-lag/list_spikes.sh b/harnesses/aggregator-head-lag/list_spikes.sh new file mode 100755 index 00000000..99d5d554 --- /dev/null +++ b/harnesses/aggregator-head-lag/list_spikes.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +PROM_URL="https://prometheus-production-0859.up.railway.app" +THRESHOLD=${1:-5} +HOURS=${2:-24} + +# Période à scanner +END_TS=$(date +%s) +START_TS=$((END_TS - HOURS * 3600)) + +echo "=== SPIKES > ${THRESHOLD}s (last ${HOURS}h) ===" +echo "" + +QUERY='head_lag_seconds{aggregator="mobula"}' +ENCODED_QUERY=$(printf %s "$QUERY" | jq -sRr @uri) + +# Fetch avec step plus large pour moins de données +curl -s "${PROM_URL}/api/v1/query_range?query=${ENCODED_QUERY}&start=${START_TS}&end=${END_TS}&step=15" | \ +jq -r --argjson threshold "$THRESHOLD" ' + .data.result[]? | + .metric as $m | + (.values // [])[] | + (.[1] | tonumber) as $val | + select($val > $threshold) | + "\(.[0])|\($m.region // "unknown")|\($m.chain // "unknown")|\($val)" +' | sort -t'|' -k1 -n | while IFS='|' read -r ts region chain val; do + DATE=$(date -r "$ts" '+%Y-%m-%d %H:%M:%S') + printf "%-19s | [%-8s] mobula - %-8s | %.2fs\n" "$DATE" "$region" "$chain" "$val" +done | tee /tmp/spikes_list.txt + +echo "" +COUNT=$(wc -l < /tmp/spikes_list.txt | tr -d ' ') +echo "Total: $COUNT spikes found" +echo "" +echo "Usage: $0 [threshold] [hours]" diff --git a/harnesses/aggregator-head-lag/monitoring/alert_rules.yml b/harnesses/aggregator-head-lag/monitoring/alert_rules.yml new file mode 100644 index 00000000..e08b8a73 --- /dev/null +++ b/harnesses/aggregator-head-lag/monitoring/alert_rules.yml @@ -0,0 +1,127 @@ +groups: + - name: aggregator_latency_alerts + interval: 30s + rules: + # Missing metrics alerts + - alert: MissingMobulaMetrics + expr: absent(rest_api_latency_milliseconds_count{aggregator="mobula"}) + for: 5m + labels: + severity: critical + aggregator: mobula + alert_type: missing_metrics + annotations: + summary: "Mobula metrics are missing" + description: "No REST API metrics received from Mobula for 5 minutes. Check API key and monitor status." + + # Stale metrics alerts (>20 minutes without update) + - alert: MobulaEthereumStaleMetrics + expr: (time() - timestamp(rest_api_latency_milliseconds_count{aggregator="mobula",chain="ethereum"})) > 1200 + for: 2m + labels: + severity: warning + aggregator: mobula + chain: ethereum + alert_type: stale_metrics + annotations: + summary: "Mobula Ethereum metrics are stale" + description: "Mobula Ethereum REST API hasn't responded in over 20 minutes. Check API connectivity." + + - alert: MobulaBaseStaleMetrics + expr: (time() - timestamp(rest_api_latency_milliseconds_count{aggregator="mobula",chain="base"})) > 1200 + for: 2m + labels: + severity: warning + aggregator: mobula + chain: base + alert_type: stale_metrics + annotations: + summary: "Mobula Base metrics are stale" + description: "Mobula Base REST API hasn't responded in over 20 minutes. Check API connectivity." + + # Latency spike alerts + - alert: MobulaEthereumLatencySpike + expr: | + ( + rate(rest_api_latency_milliseconds_sum{aggregator="mobula",chain="ethereum"}[5m]) / + rate(rest_api_latency_milliseconds_count{aggregator="mobula",chain="ethereum"}[5m]) + ) > 500 + for: 3m + labels: + severity: warning + aggregator: mobula + chain: ethereum + alert_type: latency_spike + annotations: + summary: "Mobula Ethereum latency spike" + description: "Mobula Ethereum average latency is {{ $value | humanize }}ms over the last 5 minutes (threshold: 500ms)" + + - alert: MobulaBaseLatencySpike + expr: | + ( + rate(rest_api_latency_milliseconds_sum{aggregator="mobula",chain="base"}[5m]) / + rate(rest_api_latency_milliseconds_count{aggregator="mobula",chain="base"}[5m]) + ) > 500 + for: 3m + labels: + severity: warning + aggregator: mobula + chain: base + alert_type: latency_spike + annotations: + summary: "Mobula Base latency spike" + description: "Mobula Base average latency is {{ $value | humanize }}ms over the last 5 minutes (threshold: 500ms)" + + # Instant spike detection for Mobula (any single request >10s) + - alert: MobulaInstantLatencySpike + expr: | + rest_api_latency_milliseconds{aggregator="mobula"} > 10000 + labels: + severity: warning + aggregator: mobula + alert_type: instant_spike + annotations: + summary: "Mobula instant latency spike on {{ $labels.chain }}" + description: "Mobula {{ $labels.chain }} single request took {{ $value | humanize }}ms (>10s). Brief spike detected." + + # Extreme latency spikes (>5x normal) + - alert: MobulaExtremeLatencySpike + expr: | + ( + rate(rest_api_latency_milliseconds_sum{aggregator="mobula"}[5m]) / + rate(rest_api_latency_milliseconds_count{aggregator="mobula"}[5m]) + ) > 1000 + for: 1m + labels: + severity: critical + aggregator: mobula + alert_type: extreme_latency + annotations: + summary: "Mobula EXTREME latency spike on {{ $labels.chain }}" + description: "Mobula {{ $labels.chain }} latency is {{ $value | humanize }}ms (>1000ms). Possible service degradation." + + # Error rate alerts + - alert: HighRESTErrorRate + expr: | + ( + rate(rest_api_errors_total[5m]) / + (rate(rest_api_errors_total[5m]) + rate(rest_api_latency_milliseconds_count[5m])) + ) > 0.1 + for: 5m + labels: + severity: warning + alert_type: high_error_rate + annotations: + summary: "High REST API error rate for {{ $labels.aggregator }} {{ $labels.chain }}" + description: "Error rate is {{ $value | humanizePercentage }} over the last 5 minutes (threshold: 10%)" + + # Service availability + - alert: CodexServiceDown + expr: up{job="latency_monitor"} == 0 + for: 2m + labels: + severity: critical + alert_type: service_down + annotations: + summary: "Latency monitor service is down" + description: "The aggregator latency monitor has been down for 2 minutes. No metrics are being collected." diff --git a/harnesses/aggregator-head-lag/monitoring/alertmanager.yml b/harnesses/aggregator-head-lag/monitoring/alertmanager.yml new file mode 100644 index 00000000..77b9e263 --- /dev/null +++ b/harnesses/aggregator-head-lag/monitoring/alertmanager.yml @@ -0,0 +1,36 @@ +global: + resolve_timeout: 5m + +route: + group_by: ['alertname', 'chain', 'aggregator'] + group_wait: 10s + group_interval: 30s + repeat_interval: 4h + receiver: 'slack-webhook' + +receivers: + - name: 'slack-webhook' + webhook_configs: + - url: 'https://agent-slack-production.up.railway.app/webhook/grafana' + send_resolved: true + +inhibit_rules: + # Inhibit warning alerts if critical alert is firing + - source_match: + severity: 'critical' + target_match: + severity: 'warning' + equal: ['alertname', 'chain', 'aggregator'] + + # Inhibit stale metrics alerts if service is down + - source_match: + alert_type: 'service_down' + target_match: + alert_type: 'stale_metrics' + + # Inhibit stale metrics if missing metrics alert is firing + - source_match: + alert_type: 'missing_metrics' + target_match: + alert_type: 'stale_metrics' + equal: ['aggregator'] diff --git a/harnesses/aggregator-head-lag/monitoring/grafana/dashboards/disabled/quote_api_latency.json b/harnesses/aggregator-head-lag/monitoring/grafana/dashboards/disabled/quote_api_latency.json new file mode 100644 index 00000000..16392e30 --- /dev/null +++ b/harnesses/aggregator-head-lag/monitoring/grafana/dashboards/disabled/quote_api_latency.json @@ -0,0 +1,819 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 3, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 500 + }, + { + "color": "red", + "value": 2000 + } + ] + }, + "unit": "ms" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "orange", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*jupiter.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*kyberswap.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "blue", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*lifi.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "purple", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*paraswap.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "yellow", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*openocean.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": ["mean", "lastNotNull", "max", "min"], + "displayMode": "table", + "placement": "right", + "showLegend": true, + "sortBy": "Mean", + "sortDesc": false + }, + "tooltip": { + "mode": "multi", + "sort": "asc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.50, sum(rate(quote_api_latency_milliseconds_bucket{chain!=\"arbitrum\"}[1m])) by (le, provider, chain))", + "legendFormat": "{{provider}} - {{chain}} (P50)", + "range": true, + "refId": "A" + } + ], + "title": "Quote API Latency Comparison - All Providers (P50)", + "description": "Median Quote API response time - Mobula vs competitors (30s polling interval)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 300 + }, + { + "color": "orange", + "value": 700 + }, + { + "color": "red", + "value": 1500 + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 24, + "x": 0, + "y": 10 + }, + "id": 2, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.50, sum(rate(quote_api_latency_milliseconds_bucket[5m])) by (le, provider))", + "legendFormat": "{{provider}}", + "range": true, + "refId": "A" + } + ], + "title": "Latest Quote API Latency (P50) by Provider", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 3, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 1000 + } + ] + }, + "unit": "ms" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "orange", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*jupiter.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 3, + "options": { + "legend": { + "calcs": ["mean", "lastNotNull", "max"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "asc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.50, sum(rate(quote_api_latency_milliseconds_bucket{chain=\"solana\"}[1m])) by (le, provider))", + "legendFormat": "{{provider}} (P50)", + "range": true, + "refId": "A" + } + ], + "title": "\ud83c\udf1e Solana Quote APIs - Mobula vs Jupiter", + "description": "Solana swap quote latency comparison", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 3, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 1000 + } + ] + }, + "unit": "ms" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "orange", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 16 + }, + "id": 4, + "options": { + "legend": { + "calcs": ["mean", "lastNotNull", "max"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "asc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.50, sum(rate(quote_api_latency_milliseconds_bucket{chain=\"base\"}[1m])) by (le, provider))", + "legendFormat": "{{provider}} (P50)", + "range": true, + "refId": "A" + } + ], + "title": "\ud83d\udd35 Base Quote APIs - Mobula vs Competitors", + "description": "Base chain swap quote latency comparison", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "bars", + "fillOpacity": 80, + "gradientMode": "hue", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 12, + "x": 0, + "y": 32 + }, + "id": 7, + "options": { + "legend": { + "calcs": ["sum"], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(increase(quote_api_errors_total{chain!=\"arbitrum\"}[5m])) by (provider, chain)", + "legendFormat": "{{provider}} - {{chain}}", + "range": true, + "refId": "A" + } + ], + "title": "Quote API Errors (Last 5min)", + "description": "Number of errors per provider and chain", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 95 + }, + { + "color": "red", + "value": 99 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 12, + "x": 12, + "y": 32 + }, + "id": 8, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "100 * sum(rate(quote_api_status_codes_total{status_code=\"200\"}[5m])) by (provider) / sum(rate(quote_api_status_codes_total[5m])) by (provider)", + "legendFormat": "{{provider}}", + "range": true, + "refId": "A" + } + ], + "title": "Quote API Success Rate by Provider", + "description": "Percentage of successful (200) responses", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 38 + }, + "id": 9, + "options": { + "legend": { + "calcs": ["mean", "lastNotNull", "max"], + "displayMode": "table", + "placement": "right", + "showLegend": true, + "sortBy": "Mean", + "sortDesc": false + }, + "tooltip": { + "mode": "multi", + "sort": "asc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.50, sum(rate(quote_api_latency_milliseconds_bucket{provider=\"mobula\",chain!=\"arbitrum\"}[1m])) by (le, chain))", + "legendFormat": "Mobula - {{chain}} (P50)", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.95, sum(rate(quote_api_latency_milliseconds_bucket{provider=\"mobula\",chain!=\"arbitrum\"}[1m])) by (le, chain))", + "legendFormat": "Mobula - {{chain}} (P95)", + "range": true, + "refId": "B" + } + ], + "title": "Mobula Quote API Latency by Chain (P50 & P95)", + "description": "Mobula swap quote latency across supported chains", + "type": "timeseries" + } + ], + "refresh": "5s", + "schemaVersion": 39, + "tags": ["quote-api", "swap", "latency", "mobula", "jupiter", "benchmark"], + "templating": { + "list": [] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Quote API Latency Benchmark", + "uid": "quote_api_latency", + "version": 0, + "weekStart": "" +} diff --git a/harnesses/aggregator-head-lag/monitoring/grafana/dashboards/head_lag.json b/harnesses/aggregator-head-lag/monitoring/grafana/dashboards/head_lag.json new file mode 100644 index 00000000..02606a47 --- /dev/null +++ b/harnesses/aggregator-head-lag/monitoring/grafana/dashboards/head_lag.json @@ -0,0 +1,773 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Seconds Behind", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "line" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 30 + }, + { + "color": "orange", + "value": 60 + }, + { + "color": "red", + "value": 300 + } + ] + }, + "unit": "s" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "orange", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*codex.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*geckoterminal.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "purple", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "calcs": ["median", "lastNotNull", "max", "min"], + "displayMode": "table", + "placement": "right", + "showLegend": true, + "sortBy": "Median", + "sortDesc": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "head_lag_seconds{chain!=\"ethereum\"}", + "legendFormat": "{{aggregator}} - {{chain}}", + "range": true, + "refId": "A" + } + ], + "title": "Head Lag (Estimated Seconds Behind)", + "description": "Estimated time in seconds the aggregator is behind the blockchain head. Calculated using average block time per chain.", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 5 + }, + { + "color": "orange", + "value": 10 + }, + { + "color": "red", + "value": 30 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 18 + }, + "id": 3, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "head_lag_seconds{aggregator=\"mobula\",chain!=\"ethereum\"}", + "legendFormat": "{{chain}}", + "range": true, + "refId": "A" + } + ], + "title": "Mobula - Current Head Lag (Seconds)", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 5 + }, + { + "color": "orange", + "value": 10 + }, + { + "color": "red", + "value": 30 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 18 + }, + "id": 4, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "head_lag_seconds{aggregator=\"codex\",chain!=\"ethereum\"}", + "legendFormat": "{{chain}}", + "range": true, + "refId": "A" + } + ], + "title": "Codex - Current Head Lag (Seconds)", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 5 + }, + { + "color": "orange", + "value": 10 + }, + { + "color": "red", + "value": 30 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 18 + }, + "id": 5, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "head_lag_seconds{aggregator=\"geckoterminal\",chain!=\"ethereum\"}", + "legendFormat": "{{chain}}", + "range": true, + "refId": "A" + } + ], + "title": "GeckoTerminal - Current Head Lag (Seconds)", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Seconds", + "axisPlacement": "auto", + "drawStyle": "line", + "fillOpacity": 10, + "lineInterpolation": "linear", + "lineWidth": 2, + "showPoints": "auto" + }, + "unit": "s", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 5 + }, + { + "color": "red", + "value": 20 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "orange", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*codex.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*geckoterminal.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "purple", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 10 + }, + "id": 6, + "options": { + "legend": { + "calcs": ["lastNotNull"], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "head_lag_seconds{region=\"eu-west\",chain!=\"ethereum\"}", + "legendFormat": "{{aggregator}} - {{chain}}", + "refId": "A" + } + ], + "title": "EU West - Head Lag", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Seconds", + "axisPlacement": "auto", + "drawStyle": "line", + "fillOpacity": 10, + "lineInterpolation": "linear", + "lineWidth": 2, + "showPoints": "auto" + }, + "unit": "s", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 5 + }, + { + "color": "red", + "value": 20 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "orange", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*codex.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*geckoterminal.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "purple", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 10 + }, + "id": 7, + "options": { + "legend": { + "calcs": ["lastNotNull"], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "head_lag_seconds{region=\"us-west\",chain!=\"ethereum\"}", + "legendFormat": "{{aggregator}} - {{chain}}", + "refId": "A" + } + ], + "title": "US West - Head Lag", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Seconds", + "axisPlacement": "auto", + "drawStyle": "line", + "fillOpacity": 10, + "lineInterpolation": "linear", + "lineWidth": 2, + "showPoints": "auto" + }, + "unit": "s", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 5 + }, + { + "color": "red", + "value": 20 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "orange", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*codex.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*geckoterminal.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "purple", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 10 + }, + "id": 8, + "options": { + "legend": { + "calcs": ["lastNotNull"], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "head_lag_seconds{region=\"singapore\",chain!=\"ethereum\"}", + "legendFormat": "{{aggregator}} - {{chain}}", + "refId": "A" + } + ], + "title": "Singapore - Head Lag", + "type": "timeseries" + } + ], + "refresh": "30s", + "schemaVersion": 39, + "tags": ["head-lag", "indexation", "blockchain", "sync"], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Head Lag Monitor - Blockchain vs Aggregator Sync", + "uid": "head_lag_monitor", + "version": 0, + "weekStart": "" +} diff --git a/harnesses/aggregator-head-lag/monitoring/grafana/provisioning/dashboards/dashboard.yml b/harnesses/aggregator-head-lag/monitoring/grafana/provisioning/dashboards/dashboard.yml new file mode 100644 index 00000000..49b448da --- /dev/null +++ b/harnesses/aggregator-head-lag/monitoring/grafana/provisioning/dashboards/dashboard.yml @@ -0,0 +1,13 @@ +apiVersion: 1 + +providers: + - name: 'Aggregator Latency Dashboards' + orgId: 1 + folder: '' + type: file + disableDeletion: false + updateIntervalSeconds: 10 + allowUiUpdates: true + options: + path: /var/lib/grafana/dashboards + foldersFromFilesStructure: true diff --git a/harnesses/aggregator-head-lag/monitoring/grafana/provisioning/datasources/prometheus.yml b/harnesses/aggregator-head-lag/monitoring/grafana/provisioning/datasources/prometheus.yml new file mode 100644 index 00000000..40a18e69 --- /dev/null +++ b/harnesses/aggregator-head-lag/monitoring/grafana/provisioning/datasources/prometheus.yml @@ -0,0 +1,10 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: ${PROMETHEUS_URL:-http://prometheus:9090} + uid: prometheus + isDefault: true + editable: false diff --git a/harnesses/aggregator-head-lag/monitoring/prometheus.yml b/harnesses/aggregator-head-lag/monitoring/prometheus.yml new file mode 100644 index 00000000..bd7cf041 --- /dev/null +++ b/harnesses/aggregator-head-lag/monitoring/prometheus.yml @@ -0,0 +1,21 @@ +global: + scrape_interval: 5s + evaluation_interval: 5s + +# Load alert rules +rule_files: + - '/etc/prometheus/alert_rules.yml' + +# Alertmanager configuration +alerting: + alertmanagers: + - static_configs: + - targets: ['alertmanager:9093'] + +scrape_configs: + - job_name: 'latency_monitor' + static_configs: + - targets: ['monitor:2112'] + labels: + app: 'aggregator_latency_monitor' + environment: 'production' diff --git a/harnesses/aggregator-head-lag/prometheus/Dockerfile b/harnesses/aggregator-head-lag/prometheus/Dockerfile new file mode 100644 index 00000000..a422384f --- /dev/null +++ b/harnesses/aggregator-head-lag/prometheus/Dockerfile @@ -0,0 +1,14 @@ +FROM prom/prometheus:v2.49.1 + +USER root + +# Pick which prometheus.yml to bake in (prometheus.yml for prod, prometheus.staging.yml for staging). +ARG PROMETHEUS_CONFIG=prometheus.yml + +COPY ${PROMETHEUS_CONFIG} /etc/prometheus/prometheus.yml +COPY alert_rules.yml /etc/prometheus/alert_rules.yml + +EXPOSE 9090 + +# --enable-feature=expand-external-labels: lets global.external_labels read ${ENVIRONMENT} from env +CMD ["--config.file=/etc/prometheus/prometheus.yml", "--storage.tsdb.path=/prometheus", "--web.enable-lifecycle", "--web.enable-admin-api", "--enable-feature=expand-external-labels"] diff --git a/harnesses/aggregator-head-lag/prometheus/alert_rules.yml b/harnesses/aggregator-head-lag/prometheus/alert_rules.yml new file mode 100644 index 00000000..dd89ab5e --- /dev/null +++ b/harnesses/aggregator-head-lag/prometheus/alert_rules.yml @@ -0,0 +1,223 @@ +groups: + - name: aggregator_latency_alerts + interval: 30s + rules: + # Missing metrics alerts + - alert: MissingMobulaMetrics + expr: absent(rest_api_latency_milliseconds_count{aggregator="mobula"}) + for: 5m + labels: + severity: critical + aggregator: mobula + alert_type: missing_metrics + app: aggregator_latency_monitor + annotations: + summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}Mobula metrics are missing' + description: "No REST API metrics received from Mobula for 5 minutes. Check API key and monitor status." + + # Stale metrics alerts (>20 minutes without update) + - alert: MobulaBaseStaleMetrics + expr: (time() - timestamp(rest_api_latency_milliseconds_count{aggregator="mobula",chain="base"})) > 1200 + for: 2m + labels: + severity: warning + aggregator: mobula + chain: base + alert_type: stale_metrics + app: aggregator_latency_monitor + annotations: + summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}Mobula Base metrics are stale' + description: "Mobula Base REST API hasn't responded in over 20 minutes. Check API connectivity." + + # Latency spike alerts + - alert: MobulaSolanaLatencySpike + expr: | + ( + rate(rest_api_latency_milliseconds_sum{aggregator="mobula",chain="solana"}[5m]) / + rate(rest_api_latency_milliseconds_count{aggregator="mobula",chain="solana"}[5m]) + ) > 2000 + for: 3m + labels: + severity: warning + aggregator: mobula + chain: solana + alert_type: latency_spike + app: aggregator_latency_monitor + annotations: + summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}Mobula Solana latency spike' + description: "Mobula Solana average latency is {{ $value | humanize }}ms over the last 5 minutes (threshold: 2000ms)" + + - alert: MobulaBaseLatencySpike + expr: | + ( + rate(rest_api_latency_milliseconds_sum{aggregator="mobula",chain="base"}[5m]) / + rate(rest_api_latency_milliseconds_count{aggregator="mobula",chain="base"}[5m]) + ) > 2500 + for: 3m + labels: + severity: warning + aggregator: mobula + chain: base + alert_type: latency_spike + app: aggregator_latency_monitor + annotations: + summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}Mobula Base latency spike' + description: "Mobula Base average latency is {{ $value | humanize }}ms over the last 5 minutes (threshold: 2500ms)" + + - alert: MobulaBNBLatencySpike + expr: | + ( + rate(rest_api_latency_milliseconds_sum{aggregator="mobula",chain="bnb"}[5m]) / + rate(rest_api_latency_milliseconds_count{aggregator="mobula",chain="bnb"}[5m]) + ) > 2500 + for: 3m + labels: + severity: warning + aggregator: mobula + chain: bnb + alert_type: latency_spike + app: aggregator_latency_monitor + annotations: + summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}Mobula BNB latency spike' + description: "Mobula BNB average latency is {{ $value | humanize }}ms over the last 5 minutes (threshold: 2500ms)" + + # Instant spike detection for Mobula (any single request >10s) + - alert: MobulaInstantLatencySpike + expr: | + rest_api_latency_milliseconds{aggregator="mobula"} > 10000 + labels: + severity: warning + aggregator: mobula + alert_type: instant_spike + app: aggregator_latency_monitor + annotations: + summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}Mobula instant latency spike on {{ $labels.chain }}' + description: "Mobula {{ $labels.chain }} single request took {{ $value | humanize }}ms (>10s). Brief spike detected." + + # Error rate alerts + - alert: HighRESTErrorRate + expr: | + ( + rate(rest_api_errors_total[5m]) / + (rate(rest_api_errors_total[5m]) + rate(rest_api_latency_milliseconds_count[5m])) + ) > 0.1 + for: 5m + labels: + severity: warning + alert_type: high_error_rate + app: aggregator_latency_monitor + annotations: + summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}High REST API error rate for {{ $labels.aggregator }} {{ $labels.chain }}' + description: "Error rate is {{ $value | humanizePercentage }} over the last 5 minutes (threshold: 10%)" + + # Head lag (WebSocket latency) alerts - per-chain thresholds + # NOTE: alerts use fixed-cardinality gauges now (no tx_hash label) to avoid stale-series spam. + # Sustained 30s window avoids single-spike firings (reconnect bursts, transient network jitter). + - alert: MobulaHeadLagSpikeBase + expr: mobula_head_lag_detailed_seconds{chain="base"} > 2.5 + for: 30s + labels: + severity: warning + aggregator: mobula + alert_type: head_lag_spike + app: aggregator_latency_monitor + chain: base + annotations: + summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}Mobula head lag spike on Base' + description: | + **Mobula WebSocket Latency Spike - Base** + + **Latency:** {{ $value }}s (threshold: 2.5s, sustained ≥30s) + • Mobula Processing: {{ with query (printf "mobula_processing_lag_seconds{chain=\"base\",region=\"%s\",pool_address=\"%s\"}" $labels.region $labels.pool_address) }}{{ . | first | value }}s{{ end }} + • Network: {{ with query (printf "mobula_network_lag_seconds{chain=\"base\",region=\"%s\",pool_address=\"%s\"}" $labels.region $labels.pool_address) }}{{ . | first | value }}s{{ end }} + + • Pool: {{ $labels.pool_address }} + • Region: {{ $labels.region }} + • Latest tx: {{ with query (printf "mobula_last_tx_hash{chain=\"base\",region=\"%s\",pool_address=\"%s\"}" $labels.region $labels.pool_address) }}https://basescan.org/tx/{{ . | first | label "tx_hash" }}{{ end }} + + - alert: MobulaHeadLagSpikeSolana + expr: mobula_head_lag_detailed_seconds{chain="solana"} > 2 + for: 30s + labels: + severity: warning + aggregator: mobula + alert_type: head_lag_spike + app: aggregator_latency_monitor + chain: solana + annotations: + summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}Mobula head lag spike on Solana' + description: | + **Mobula WebSocket Latency Spike - Solana** + + **Latency:** {{ $value }}s (threshold: 2s, sustained ≥30s) + • Mobula Processing: {{ with query (printf "mobula_processing_lag_seconds{chain=\"solana\",region=\"%s\",pool_address=\"%s\"}" $labels.region $labels.pool_address) }}{{ . | first | value }}s{{ end }} + • Network: {{ with query (printf "mobula_network_lag_seconds{chain=\"solana\",region=\"%s\",pool_address=\"%s\"}" $labels.region $labels.pool_address) }}{{ . | first | value }}s{{ end }} + + • Pool: {{ $labels.pool_address }} + • Region: {{ $labels.region }} + • Latest tx: {{ with query (printf "mobula_last_tx_hash{chain=\"solana\",region=\"%s\",pool_address=\"%s\"}" $labels.region $labels.pool_address) }}https://solscan.io/tx/{{ . | first | label "tx_hash" }}{{ end }} + + - alert: MobulaHeadLagSpikeBNB + expr: mobula_head_lag_detailed_seconds{chain="bnb"} > 2.5 + for: 30s + labels: + severity: warning + aggregator: mobula + alert_type: head_lag_spike + app: aggregator_latency_monitor + chain: bnb + annotations: + summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}Mobula head lag spike on BNB Chain' + description: | + **Mobula WebSocket Latency Spike - BNB Chain** + + **Latency:** {{ $value }}s (threshold: 2.5s, sustained ≥30s) + • Mobula Processing: {{ with query (printf "mobula_processing_lag_seconds{chain=\"bnb\",region=\"%s\",pool_address=\"%s\"}" $labels.region $labels.pool_address) }}{{ . | first | value }}s{{ end }} + • Network: {{ with query (printf "mobula_network_lag_seconds{chain=\"bnb\",region=\"%s\",pool_address=\"%s\"}" $labels.region $labels.pool_address) }}{{ . | first | value }}s{{ end }} + + • Pool: {{ $labels.pool_address }} + • Region: {{ $labels.region }} + • Latest tx: {{ with query (printf "mobula_last_tx_hash{chain=\"bnb\",region=\"%s\",pool_address=\"%s\"}" $labels.region $labels.pool_address) }}https://bscscan.com/tx/{{ . | first | label "tx_hash" }}{{ end }} + + # Missing head lag metrics (no data for 5 minutes = monitor down) + - alert: MissingHeadLagMetrics + expr: absent(head_lag_seconds) + for: 5m + labels: + severity: critical + alert_type: missing_head_lag + app: aggregator_latency_monitor + annotations: + summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}Head lag metrics missing' + description: "No head_lag_seconds metrics received for 5 minutes. Check if monitors are running." + + # Per-aggregator staleness: fires when one provider stops pushing data + # (e.g. Codex WS disconnect) while others keep running — global absent() + # would NOT catch this. + - alert: AggregatorHeadLagStale + expr: (time() - timestamp(head_lag_seconds)) > 300 + for: 1m + labels: + severity: warning + alert_type: head_lag_stale + app: aggregator_latency_monitor + annotations: + summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}{{ $labels.aggregator }} head_lag stale on {{ $labels.chain }} ({{ $labels.region }})' + description: | + **{{ $labels.aggregator }}** hasn't pushed a head_lag sample for **{{ $labels.chain }} / {{ $labels.region }}** in over 5 minutes. + + Likely cause: WebSocket disconnected, JWT expired, proxy IP banned, or auth cookie rotated. + + Check the monitor logs for `[HEAD-LAG][{{ $labels.aggregator | toUpper }}]` errors. + + # Service availability + - alert: CodexServiceDown + expr: up{job="latency_monitor"} == 0 + for: 2m + labels: + severity: critical + alert_type: service_down + app: aggregator_latency_monitor + annotations: + summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}Latency monitor service is down' + description: "The aggregator latency monitor has been down for 2 minutes. No metrics are being collected." diff --git a/harnesses/aggregator-head-lag/prometheus/alertmanager.yml b/harnesses/aggregator-head-lag/prometheus/alertmanager.yml new file mode 100644 index 00000000..77b9e263 --- /dev/null +++ b/harnesses/aggregator-head-lag/prometheus/alertmanager.yml @@ -0,0 +1,36 @@ +global: + resolve_timeout: 5m + +route: + group_by: ['alertname', 'chain', 'aggregator'] + group_wait: 10s + group_interval: 30s + repeat_interval: 4h + receiver: 'slack-webhook' + +receivers: + - name: 'slack-webhook' + webhook_configs: + - url: 'https://agent-slack-production.up.railway.app/webhook/grafana' + send_resolved: true + +inhibit_rules: + # Inhibit warning alerts if critical alert is firing + - source_match: + severity: 'critical' + target_match: + severity: 'warning' + equal: ['alertname', 'chain', 'aggregator'] + + # Inhibit stale metrics alerts if service is down + - source_match: + alert_type: 'service_down' + target_match: + alert_type: 'stale_metrics' + + # Inhibit stale metrics if missing metrics alert is firing + - source_match: + alert_type: 'missing_metrics' + target_match: + alert_type: 'stale_metrics' + equal: ['aggregator'] diff --git a/harnesses/aggregator-head-lag/prometheus/prometheus.staging.yml b/harnesses/aggregator-head-lag/prometheus/prometheus.staging.yml new file mode 100644 index 00000000..a5c329de --- /dev/null +++ b/harnesses/aggregator-head-lag/prometheus/prometheus.staging.yml @@ -0,0 +1,26 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + # Staging external label triggers [STAGING] prefix in alert summaries via templating. + external_labels: + environment: 'staging' + +# Load alert rules (shared with production; [STAGING] prefix is conditional via $externalLabels) +rule_files: + - '/etc/prometheus/alert_rules.yml' + +# Staging Alertmanager (separate from production) +alerting: + alertmanagers: + - static_configs: + - targets: ['alertmanager-staging.railway.internal:9093'] + +# Staging scrape targets — 3 regions, same pattern as production +scrape_configs: + - job_name: 'monitor-staging' + static_configs: + - targets: + - 'agg-staging-eu.railway.internal:2112' + - 'agg-staging-us.railway.internal:2112' + - 'agg-staging-sgp.railway.internal:2112' + metrics_path: /metrics diff --git a/harnesses/aggregator-head-lag/prometheus/prometheus.yml b/harnesses/aggregator-head-lag/prometheus/prometheus.yml new file mode 100644 index 00000000..f9a4dfce --- /dev/null +++ b/harnesses/aggregator-head-lag/prometheus/prometheus.yml @@ -0,0 +1,27 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + # Expanded at startup via --enable-feature=expand-external-labels. + # Unset => empty value => alert rule templates treat it as "not staging" and don't prefix. + external_labels: + environment: '${ENVIRONMENT}' + +# Load alert rules +rule_files: + - '/etc/prometheus/alert_rules.yml' + +# Alertmanager configuration +alerting: + alertmanagers: + - static_configs: + - targets: ['alertmanager.railway.internal:9093'] + +scrape_configs: + - job_name: 'monitor' + static_configs: + - targets: + - 'agg-eu-west.railway.internal:2112' + - 'alert-reflection.railway.internal:2112' + - 'aggregator-latency-benchmark.railway.internal:2112' + metrics_path: /metrics + From 791ae1ffc10685c3cb9f8e3d38a88826e112dd7c Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Fri, 3 Jul 2026 18:01:02 +0200 Subject: [PATCH 072/144] harnesses: add missing .env.example templates (#882) Co-authored-by: Florent Tapponnier --- harnesses/evm-swap-quoting/.env.example | 20 ++++++++++++++++++ harnesses/l1-finality/.env.example | 26 ++++++++++++++++++++++++ harnesses/l2-block-time/.env.example | 16 +++++++++++++++ harnesses/metadata-coverage/.env.example | 16 +++++++++++++++ harnesses/perp-fees/.env.example | 10 +++++++++ harnesses/wallet-labels/.env.example | 22 ++++++++++++++++++++ 6 files changed, 110 insertions(+) create mode 100644 harnesses/evm-swap-quoting/.env.example create mode 100644 harnesses/l1-finality/.env.example create mode 100644 harnesses/l2-block-time/.env.example create mode 100644 harnesses/metadata-coverage/.env.example create mode 100644 harnesses/perp-fees/.env.example create mode 100644 harnesses/wallet-labels/.env.example diff --git a/harnesses/evm-swap-quoting/.env.example b/harnesses/evm-swap-quoting/.env.example new file mode 100644 index 00000000..f3842abb --- /dev/null +++ b/harnesses/evm-swap-quoting/.env.example @@ -0,0 +1,20 @@ +# Mobula required (the swap quoting endpoint is auth-gated). +MOBULA_API_KEY= + +# Region label that lands on every metric. Set per Railway replica. +# Allowed: us-east, eu-west, sgp +MONITOR_REGION=eu-west + +# Optional: free-tier keys for the 3 providers that need them. Without these +# the harness simply skips the corresponding provider; metrics for that +# provider stay zero. Sign-up: +# https://portal.1inch.dev (100k req/month, 1 RPS) +# https://dashboard.0x.org (200k req/month) +# https://app.odos.xyz/api (avoids the anonymous 3-call rate limit) +ONEINCH_API_KEY= +ZEROX_API_KEY= +ODOS_API_KEY= + +# Optional token-gate on /logs?tail=N. Same shared token as the other OCB +# harnesses if you want one curl to fetch logs across services. +LOGS_TOKEN= diff --git a/harnesses/l1-finality/.env.example b/harnesses/l1-finality/.env.example new file mode 100644 index 00000000..99db34c3 --- /dev/null +++ b/harnesses/l1-finality/.env.example @@ -0,0 +1,26 @@ +# Override any RPC endpoint with your own (Alchemy, QuickNode, Infura, …). +# Defaults are public RPCs that may rate-limit under sustained load. +RPC_ETHEREUM= +RPC_BNB= +RPC_AVALANCHE= +RPC_SOLANA= +RPC_TRON= +RPC_XRP= +RPC_STELLAR= +RPC_HEDERA= +RPC_SUI= +RPC_TON= +RPC_LITECOIN= +RPC_MONERO= +RPC_CARDANO= + +# Toncenter rate-limits anonymous traffic at ~1 rps. Get a free key at +# https://t.me/tonapibot and set it here so the TON fetcher hits the +# higher 10-rps tier. Without it, this chain will report status_429. +TON_API_KEY= + +# Refresh cadence in seconds. Default 10. Min 5. +REFRESH_INTERVAL_SECONDS=10 + +# Optional bearer token to gate /logs. +LOGS_TOKEN= diff --git a/harnesses/l2-block-time/.env.example b/harnesses/l2-block-time/.env.example new file mode 100644 index 00000000..3b66ed5a --- /dev/null +++ b/harnesses/l2-block-time/.env.example @@ -0,0 +1,16 @@ +# L2 sequencer WebSocket endpoints. Defaults are public no-key +# endpoints verified live at inception (see README probe results). +# Override any of these without a rebuild if an upstream goes flaky. + +# RPC_WS_ARBITRUM=wss://arbitrum-one-rpc.publicnode.com +# RPC_WS_OPTIMISM=wss://optimism-rpc.publicnode.com +# RPC_WS_BASE=wss://base-rpc.publicnode.com +# RPC_WS_ZKSYNC=wss://mainnet.era.zksync.io/ws +# RPC_WS_LINEA=wss://linea-rpc.publicnode.com +# RPC_WS_SCROLL=wss://scroll-rpc.publicnode.com +# RPC_WS_BLAST=wss://blast-rpc.publicnode.com +# RPC_WS_MANTLE=wss://mantle-rpc.publicnode.com +# RPC_WS_TAIKO=wss://taiko-rpc.publicnode.com + +# Railway injects $PORT; defaults to :2112 locally. +# PORT=2112 diff --git a/harnesses/metadata-coverage/.env.example b/harnesses/metadata-coverage/.env.example new file mode 100644 index 00000000..e0db8c0c --- /dev/null +++ b/harnesses/metadata-coverage/.env.example @@ -0,0 +1,16 @@ +# CoinGecko API Key (Pro plan required for WebSocket) +COINGECKO_API_KEY=your_coingecko_api_key + +# Mobula API Key +MOBULA_API_KEY=your_mobula_api_key + +# Defined.fi Session Cookie (for Codex data) +# Optional: Will be auto-scraped anonymously if not provided +DEFINED_SESSION_COOKIE=your_defined_session_cookie + +# Grafana Admin Password (for production) +GF_SECURITY_ADMIN_PASSWORD=admin + +# Slack-bridge webhook URL the alertmanager posts to. Substituted into +# alertmanager.yml at container start by alertmanager/entrypoint.sh. +SLACK_WEBHOOK_URL=https://your-slack-bridge.example.com/webhook/your-token diff --git a/harnesses/perp-fees/.env.example b/harnesses/perp-fees/.env.example new file mode 100644 index 00000000..62b6da2e --- /dev/null +++ b/harnesses/perp-fees/.env.example @@ -0,0 +1,10 @@ +# perp-fees harness env vars +# Copy to .env and fill in for local development. + +# Address the Prometheus scrape endpoint binds to. +# Production: must be :2112 (the shared Prom on Railway scrapes this exact port). +PROM_LISTEN_ADDR=:2112 + +# Optional. If set, the /logs?tail=N endpoint requires +# `X-Logs-Token: ` for remote log inspection. Leave unset to disable. +# LOGS_TOKEN= diff --git a/harnesses/wallet-labels/.env.example b/harnesses/wallet-labels/.env.example new file mode 100644 index 00000000..dc1a7d7d --- /dev/null +++ b/harnesses/wallet-labels/.env.example @@ -0,0 +1,22 @@ +# Wallet labels coverage harness — env template. +# All keys read from env at boot. NEVER commit a real `.env`. + +# Mobula +MOBULA_API_KEY= + +# Moralis +MORALIS_API_KEY= + +# Helius (Solana) +HELIUS_API_KEY= + +# Tuning +WALLET_LABELS_CHECK_DELAY_SECONDS=30 +WALLET_LABELS_WORKERS=8 +WALLET_LABELS_QUEUE_SIZE=2000 + +# Optional override of default Prometheus listen addr +PROM_LISTEN_ADDR=:2112 + +# Optional /logs endpoint guard +LOGS_TOKEN= From 2e565b2c9b9819703b3374cef579c139f0cbdcd0 Mon Sep 17 00:00:00 2001 From: Florent Tapponnier Date: Fri, 3 Jul 2026 18:01:48 +0200 Subject: [PATCH 073/144] fix(perp-execution-quality): rename hyperliquid-xyz to hyperliquid --- benchmarks/perp-execution-quality.yml | 18 +++++++++--------- .../cmd/script/config.go | 2 +- .../perp-execution-scanner/cmd/script/main.go | 6 +++--- .../cmd/script/source_hyperliquid.go | 2 +- src/app/products/[slug]/page.tsx | 6 ------ src/data/provider-registry.ts | 6 ------ src/lib/logo-manifest.ts | 1 - 7 files changed, 14 insertions(+), 27 deletions(-) diff --git a/benchmarks/perp-execution-quality.yml b/benchmarks/perp-execution-quality.yml index a34e9624..095cc44a 100644 --- a/benchmarks/perp-execution-quality.yml +++ b/benchmarks/perp-execution-quality.yml @@ -56,7 +56,7 @@ methodology: findings: - "{{best_name}} currently leads at {{best_p50}} of slippage on a $10,000 $BOT buy (24 h average) between the {{count}} DEX venues listing $BOT." - "{{name:lighter}} sits at {{p50:lighter}} on the same $10,000 bucket. Fully onchain orderbook on zk-rollup, zero taker fee, so the number is essentially the crossed half-spread plus depth impact." - - "{{name:hyperliquid-xyz}} sits at {{p50:hyperliquid-xyz}} on the same $10,000 bucket. HIP-3 markets settle through the same HyperBFT orderbook engine as native Hyperliquid perps but liquidity is provided by the deployer-affiliated market maker, so depth can be thinner than on a native flagship pair." + - "{{name:hyperliquid}} sits at {{p50:hyperliquid}} on the same $10,000 bucket. HIP-3 markets settle through the same HyperBFT orderbook engine as native Hyperliquid perps but liquidity is provided by the deployer-affiliated market maker, so depth can be thinner than on a native flagship pair." - "Both venues publish additional size buckets ($100 up to $500,000, both sides). The full grid is exposed via the raw `perp_execution_slippage_bps` series with the `size_usd` and `side` labels." - "$BOT is not currently listed on any other decentralized perp venue with a public orderbook endpoint. New listings will show up here automatically once their `(asset, venue)` pair is added to the harness config." @@ -96,15 +96,15 @@ providers: sample_size: count_over_time(perp_execution_slippage_bps{asset="BOT", venue="lighter", side="buy", size_usd="10000"}[24h]) series: perp_execution_slippage_bps{asset="BOT", venue="lighter", side="buy", size_usd="10000"} - - slug: hyperliquid-xyz + - slug: hyperliquid name: Hyperliquid HIP-3 (xyz:BOT) tag: HIP-3 permissionless perp on Hyperliquid L1 formula: "Average over 24h of the effective slippage in bps for a $10,000 buy on the Hyperliquid HIP-3 `xyz:BOT` orderbook, walked live every 30 seconds from `POST /info {type:l2Book, coin:\"xyz:BOT\"}`." queries: - p50: avg_over_time(perp_execution_slippage_bps{asset="BOT", venue="hyperliquid-xyz", side="buy", size_usd="10000"}[24h]) - p90: quantile_over_time(0.90, perp_execution_slippage_bps{asset="BOT", venue="hyperliquid-xyz", side="buy", size_usd="10000"}[24h]) - p99: quantile_over_time(0.99, perp_execution_slippage_bps{asset="BOT", venue="hyperliquid-xyz", side="buy", size_usd="10000"}[24h]) - mean: avg_over_time(perp_execution_slippage_bps{asset="BOT", venue="hyperliquid-xyz", side="buy", size_usd="10000"}[24h]) - success: avg_over_time(perp_execution_health{asset="BOT", venue="hyperliquid-xyz"}[24h]) - sample_size: count_over_time(perp_execution_slippage_bps{asset="BOT", venue="hyperliquid-xyz", side="buy", size_usd="10000"}[24h]) - series: perp_execution_slippage_bps{asset="BOT", venue="hyperliquid-xyz", side="buy", size_usd="10000"} + p50: avg_over_time(perp_execution_slippage_bps{asset="BOT", venue="hyperliquid", side="buy", size_usd="10000"}[24h]) + p90: quantile_over_time(0.90, perp_execution_slippage_bps{asset="BOT", venue="hyperliquid", side="buy", size_usd="10000"}[24h]) + p99: quantile_over_time(0.99, perp_execution_slippage_bps{asset="BOT", venue="hyperliquid", side="buy", size_usd="10000"}[24h]) + mean: avg_over_time(perp_execution_slippage_bps{asset="BOT", venue="hyperliquid", side="buy", size_usd="10000"}[24h]) + success: avg_over_time(perp_execution_health{asset="BOT", venue="hyperliquid"}[24h]) + sample_size: count_over_time(perp_execution_slippage_bps{asset="BOT", venue="hyperliquid", side="buy", size_usd="10000"}[24h]) + series: perp_execution_slippage_bps{asset="BOT", venue="hyperliquid", side="buy", size_usd="10000"} diff --git a/harnesses/perp-execution-scanner/cmd/script/config.go b/harnesses/perp-execution-scanner/cmd/script/config.go index e1bb1b86..9a7f1ed6 100644 --- a/harnesses/perp-execution-scanner/cmd/script/config.go +++ b/harnesses/perp-execution-scanner/cmd/script/config.go @@ -18,7 +18,7 @@ type Config struct { ListenAddr string // Assets is the list of underlyings to track. Venue slugs are fixed - // (lighter, hyperliquid-xyz) and are wired to the corresponding + // (lighter, hyperliquid) and are wired to the corresponding // fetcher in main.go via a switch. Assets []AssetConfig diff --git a/harnesses/perp-execution-scanner/cmd/script/main.go b/harnesses/perp-execution-scanner/cmd/script/main.go index 67fac83f..a06f8ab9 100644 --- a/harnesses/perp-execution-scanner/cmd/script/main.go +++ b/harnesses/perp-execution-scanner/cmd/script/main.go @@ -105,9 +105,9 @@ func sweep(cfg *Config) { start := time.Now() book, err := FetchHyperliquid(asset.Asset, asset.HyperliquidCoin) if err != nil { - fetchErrorsCtr.WithLabelValues(asset.Asset, "hyperliquid-xyz", classifyError(err.Error())).Inc() - healthGauge.WithLabelValues(asset.Asset, "hyperliquid-xyz").Set(0) - fmt.Printf("[%s][hyperliquid-xyz] err: %v\n", asset.Asset, err) + fetchErrorsCtr.WithLabelValues(asset.Asset, "hyperliquid", classifyError(err.Error())).Inc() + healthGauge.WithLabelValues(asset.Asset, "hyperliquid").Set(0) + fmt.Printf("[%s][hyperliquid] err: %v\n", asset.Asset, err) return } publish(book, cfg, time.Since(start)) diff --git a/harnesses/perp-execution-scanner/cmd/script/source_hyperliquid.go b/harnesses/perp-execution-scanner/cmd/script/source_hyperliquid.go index 789f813d..91062520 100644 --- a/harnesses/perp-execution-scanner/cmd/script/source_hyperliquid.go +++ b/harnesses/perp-execution-scanner/cmd/script/source_hyperliquid.go @@ -63,7 +63,7 @@ func FetchHyperliquid(asset string, coin string) (*OrderBook, error) { return nil, fmt.Errorf("bad_shape: levels len=%d", len(r.Levels)) } book := &OrderBook{ - Venue: "hyperliquid-xyz", + Venue: "hyperliquid", Asset: asset, Bids: parseHLSide(r.Levels[0]), Asks: parseHLSide(r.Levels[1]), diff --git a/src/app/products/[slug]/page.tsx b/src/app/products/[slug]/page.tsx index 5b683ab0..3ad3525d 100644 --- a/src/app/products/[slug]/page.tsx +++ b/src/app/products/[slug]/page.tsx @@ -69,9 +69,6 @@ export async function generateMetadata({ alternates: { canonical: canonicalUrl }, }; } - if (slug === "hyperliquid-xyz") { - return { alternates: { canonical: `${SITE.url}/products/hyperliquid` } }; - } const p = await getProvider(slug); if (!p) return {}; const reg = getProviderRegistry(p.slug); @@ -145,9 +142,6 @@ export default async function ProviderPage({ if (await isHlBuilderSlug(slug)) { redirect(`/hyperliquid/${slug}`); } - if (slug === "hyperliquid-xyz") { - redirect(`/products/hyperliquid`); - } const p = await getProvider(slug); if (!p) notFound(); const reg = getProviderRegistry(p.slug); diff --git a/src/data/provider-registry.ts b/src/data/provider-registry.ts index d3130f00..05682982 100644 --- a/src/data/provider-registry.ts +++ b/src/data/provider-registry.ts @@ -269,12 +269,6 @@ export const PROVIDER_REGISTRY: Record = { "Onchain perp DEX on the Hyperliquid L1. Fully onchain orderbook with sub-second matching, no off-chain matching engine.", twitter: "@HyperliquidX", }, - "hyperliquid-xyz": { - url: "https://hyperliquid.xyz", - description: - "HIP-3 permissionless perp markets on Hyperliquid L1. Third-party deployers stake HYPE to list their own perps under a `deployer:SYMBOL` coin (e.g. `xyz:BOT`), settled by the same HyperBFT orderbook infrastructure as native Hyperliquid perps.", - twitter: "@HyperliquidX", - }, dydx: { url: "https://dydx.trade", description: diff --git a/src/lib/logo-manifest.ts b/src/lib/logo-manifest.ts index 5543848a..08c49fb7 100644 --- a/src/lib/logo-manifest.ts +++ b/src/lib/logo-manifest.ts @@ -301,7 +301,6 @@ const ALIASES: Record = { sol: "solana", bsc: "bnb", hypercore: "hyperliquid", // HyperCore is the Hyperliquid trading engine, same brand - "hyperliquid-xyz": "hyperliquid", // HIP-3 permissionless perp on Hyperliquid, same brand "publicnode-feehistory": "publicnode", // Same brand split across two HL builder addresses; one logo is enough. "metamask-alt": "metamask", From 7c2c2b0efacc3a9ec2bde96a08e23eb5b747ad4d Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Fri, 3 Jul 2026 18:03:36 +0200 Subject: [PATCH 074/144] rpc hub: clarify best-overall column label (3-region avg) (#884) Co-authored-by: Florent Tapponnier --- src/components/rpc-chains-leaderboard.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/rpc-chains-leaderboard.tsx b/src/components/rpc-chains-leaderboard.tsx index 4b215c52..74ec01cf 100644 --- a/src/components/rpc-chains-leaderboard.tsx +++ b/src/components/rpc-chains-leaderboard.tsx @@ -114,7 +114,9 @@ export function RpcChainsLeaderboard({ rows }: { rows: RpcHubChain[] }) { dir={sortDir} onClick={() => setSort("bestP50")} > - Fastest provider + + Best overall (3-region avg) + {REGION_COLS.map((c) => ( Date: Fri, 3 Jul 2026 18:06:05 +0200 Subject: [PATCH 075/144] port monitoring stack, admin UIs and bridge-monitor monitor/deploy from monorepo (#886) - infrastructure/monitoring/{prometheus,grafana,alertmanager,prom-gateway} - infrastructure/monitoring-ui/ and infrastructure/prom-admin/ - harnesses/bridge-monitor: add cmd/monitor + deploy/, fix .gitignore shadowing cmd/monitor - secrets externalized: ALERT_WEBHOOK_URL (alertmanager sed entrypoint), Grafana admin creds moved to GF_* env vars, .env.example files added Co-authored-by: Florent Tapponnier --- harnesses/bridge-monitor/.env.example | 54 + harnesses/bridge-monitor/.gitignore | 2 +- .../bridge-monitor/cmd/monitor/balance.go | 294 +++ .../bridge-monitor/cmd/monitor/config.go | 243 ++ .../bridge-monitor/cmd/monitor/cycle_sim.go | 77 + .../cmd/monitor/debridge_bridge.go | 162 ++ .../bridge-monitor/cmd/monitor/executor.go | 1128 +++++++++ .../bridge-monitor/cmd/monitor/lifi_bridge.go | 193 ++ .../bridge-monitor/cmd/monitor/logbuffer.go | 113 + harnesses/bridge-monitor/cmd/monitor/main.go | 499 ++++ .../bridge-monitor/cmd/monitor/metrics.go | 131 ++ .../cmd/monitor/mobula_bridge.go | 402 ++++ .../cmd/monitor/nearintents_bridge.go | 262 +++ .../cmd/monitor/onchain_balance.go | 223 ++ .../bridge-monitor/cmd/monitor/pricer.go | 78 + .../cmd/monitor/relay_bridge.go | 238 ++ .../bridge-monitor/cmd/monitor/scheduler.go | 232 ++ harnesses/bridge-monitor/cmd/monitor/slack.go | 300 +++ .../bridge-monitor/cmd/monitor/tx_executor.go | 585 +++++ .../bridge-monitor/cmd/monitor/wallet.go | 90 + .../cmd/monitor/wallet_snapshot.go | 347 +++ harnesses/bridge-monitor/deploy/DEPLOY.md | 158 ++ .../deploy/alertmanager/Dockerfile | 15 + .../deploy/alertmanager/alertmanager.yml.tmpl | 41 + .../deploy/alertmanager/entrypoint.sh | 19 + .../deploy/alertmanager/templates.tmpl | 19 + .../bridge-monitor/deploy/docker-compose.yml | 80 + .../bridge-monitor/deploy/grafana/Dockerfile | 20 + .../dashboards/bridge-latency.json | 2044 +++++++++++++++++ .../dashboards/bridge-pricing.json | 1619 +++++++++++++ .../provisioning/dashboards/dashboard.yml | 12 + .../provisioning/datasources/prometheus.yml | 9 + .../deploy/prometheus/Dockerfile | 21 + .../deploy/prometheus/alert_rules.yml | 130 ++ .../deploy/prometheus/prometheus.yml | 19 + infrastructure/README.md | 10 +- infrastructure/monitoring-ui/.env.example | 16 + infrastructure/monitoring-ui/.gitignore | 10 + infrastructure/monitoring-ui/Dockerfile | 25 + infrastructure/monitoring-ui/README.md | 54 + infrastructure/monitoring-ui/bun.lock | 905 ++++++++ infrastructure/monitoring-ui/next.config.ts | 8 + infrastructure/monitoring-ui/package.json | 31 + .../monitoring-ui/postcss.config.js | 1 + infrastructure/monitoring-ui/public/.gitkeep | 0 infrastructure/monitoring-ui/railway.json | 11 + .../monitoring-ui/src/app/actions.ts | 30 + .../src/app/api/logs/[slug]/route.ts | 53 + .../monitoring-ui/src/app/globals.css | 13 + .../monitoring-ui/src/app/healthz/route.ts | 5 + .../monitoring-ui/src/app/layout.tsx | 16 + infrastructure/monitoring-ui/src/app/page.tsx | 298 +++ .../src/components/action-buttons.tsx | 139 ++ .../src/components/logs-modal.tsx | 159 ++ .../monitoring-ui/src/lib/bench-state.ts | 98 + .../monitoring-ui/src/lib/github.ts | 69 + .../monitoring-ui/src/lib/promote.ts | 146 ++ .../monitoring-ui/src/lib/registry.ts | 272 +++ .../monitoring-ui/src/middleware.ts | 25 + .../monitoring-ui/tailwind.config.ts | 9 + infrastructure/monitoring-ui/tsconfig.json | 21 + infrastructure/monitoring/.env.example | 31 + infrastructure/monitoring/README.md | 53 + .../monitoring/alertmanager/Dockerfile | 16 + .../alertmanager/alertmanager.yml.tmpl | 36 + .../monitoring/alertmanager/entrypoint.sh | 22 + infrastructure/monitoring/grafana/Dockerfile | 36 + .../disabled/mobula_pulse_vs_fasttrade.json | 595 +++++ .../dashboards/disabled/pulse_vs_codex.json | 661 ++++++ .../disabled/quote_api_latency.json | 916 ++++++++ .../grafana/dashboards/head_lag.json | 945 ++++++++ .../grafana/dashboards/metadata_coverage.json | 1133 +++++++++ .../monitoring/grafana/grafana-entrypoint.sh | 29 + .../provisioning/dashboards/dashboard.yml | 13 + .../provisioning/datasources/prometheus.yml | 10 + .../monitoring/prom-gateway/Caddyfile | 51 + .../monitoring/prom-gateway/Dockerfile | 12 + .../monitoring/prom-gateway/README.md | 53 + .../monitoring/prom-gateway/railway.toml | 10 + .../monitoring/prometheus/Dockerfile | 60 + .../monitoring/prometheus/alert_rules.yml | 120 + .../monitoring/prometheus/entrypoint.sh | 38 + .../prometheus/prometheus.staging.yml | 29 + .../monitoring/prometheus/prometheus.yml | 452 ++++ .../recording_rules/ocb_evm_quote_latency.yml | 26 + .../recording_rules/ocb_gas_estimation.yml | 14 + .../recording_rules/ocb_l2_block_time.yml | 15 + .../recording_rules/ocb_pm_rate_limits.yml | 18 + .../recording_rules/ocb_rpc_capabilities.yml | 32 + .../ocb_solana_dex_quote_latency.yml | 22 + infrastructure/prom-admin/.env.example | 16 + infrastructure/prom-admin/.gitignore | 4 + infrastructure/prom-admin/Dockerfile | 19 + infrastructure/prom-admin/README.md | 173 ++ .../prom-admin/app/api/chart/route.ts | 67 + .../prom-admin/app/api/delete/route.ts | 188 ++ .../prom-admin/app/api/labels/route.ts | 28 + .../prom-admin/app/api/metrics/route.ts | 19 + .../prom-admin/app/api/preview/route.ts | 91 + .../prom-admin/app/api/smart-clean/route.ts | 217 ++ infrastructure/prom-admin/app/health/route.ts | 5 + infrastructure/prom-admin/app/layout.tsx | 13 + infrastructure/prom-admin/app/lib/audit.ts | 37 + infrastructure/prom-admin/app/lib/prom.ts | 55 + .../prom-admin/app/lib/rate-limit.ts | 42 + infrastructure/prom-admin/app/lib/validate.ts | 145 ++ infrastructure/prom-admin/app/page.tsx | 693 ++++++ infrastructure/prom-admin/middleware.ts | 81 + infrastructure/prom-admin/next.config.ts | 12 + infrastructure/prom-admin/package-lock.json | 1364 +++++++++++ infrastructure/prom-admin/package.json | 23 + infrastructure/prom-admin/railway.toml | 11 + infrastructure/prom-admin/tsconfig.json | 28 + 113 files changed, 21058 insertions(+), 4 deletions(-) create mode 100644 harnesses/bridge-monitor/.env.example create mode 100644 harnesses/bridge-monitor/cmd/monitor/balance.go create mode 100644 harnesses/bridge-monitor/cmd/monitor/config.go create mode 100644 harnesses/bridge-monitor/cmd/monitor/cycle_sim.go create mode 100644 harnesses/bridge-monitor/cmd/monitor/debridge_bridge.go create mode 100644 harnesses/bridge-monitor/cmd/monitor/executor.go create mode 100644 harnesses/bridge-monitor/cmd/monitor/lifi_bridge.go create mode 100644 harnesses/bridge-monitor/cmd/monitor/logbuffer.go create mode 100644 harnesses/bridge-monitor/cmd/monitor/main.go create mode 100644 harnesses/bridge-monitor/cmd/monitor/metrics.go create mode 100644 harnesses/bridge-monitor/cmd/monitor/mobula_bridge.go create mode 100644 harnesses/bridge-monitor/cmd/monitor/nearintents_bridge.go create mode 100644 harnesses/bridge-monitor/cmd/monitor/onchain_balance.go create mode 100644 harnesses/bridge-monitor/cmd/monitor/pricer.go create mode 100644 harnesses/bridge-monitor/cmd/monitor/relay_bridge.go create mode 100644 harnesses/bridge-monitor/cmd/monitor/scheduler.go create mode 100644 harnesses/bridge-monitor/cmd/monitor/slack.go create mode 100644 harnesses/bridge-monitor/cmd/monitor/tx_executor.go create mode 100644 harnesses/bridge-monitor/cmd/monitor/wallet.go create mode 100644 harnesses/bridge-monitor/cmd/monitor/wallet_snapshot.go create mode 100644 harnesses/bridge-monitor/deploy/DEPLOY.md create mode 100644 harnesses/bridge-monitor/deploy/alertmanager/Dockerfile create mode 100644 harnesses/bridge-monitor/deploy/alertmanager/alertmanager.yml.tmpl create mode 100755 harnesses/bridge-monitor/deploy/alertmanager/entrypoint.sh create mode 100644 harnesses/bridge-monitor/deploy/alertmanager/templates.tmpl create mode 100644 harnesses/bridge-monitor/deploy/docker-compose.yml create mode 100644 harnesses/bridge-monitor/deploy/grafana/Dockerfile create mode 100644 harnesses/bridge-monitor/deploy/grafana/provisioning/dashboards/bridge-latency.json create mode 100644 harnesses/bridge-monitor/deploy/grafana/provisioning/dashboards/bridge-pricing.json create mode 100644 harnesses/bridge-monitor/deploy/grafana/provisioning/dashboards/dashboard.yml create mode 100644 harnesses/bridge-monitor/deploy/grafana/provisioning/datasources/prometheus.yml create mode 100644 harnesses/bridge-monitor/deploy/prometheus/Dockerfile create mode 100644 harnesses/bridge-monitor/deploy/prometheus/alert_rules.yml create mode 100644 harnesses/bridge-monitor/deploy/prometheus/prometheus.yml create mode 100644 infrastructure/monitoring-ui/.env.example create mode 100644 infrastructure/monitoring-ui/.gitignore create mode 100644 infrastructure/monitoring-ui/Dockerfile create mode 100644 infrastructure/monitoring-ui/README.md create mode 100644 infrastructure/monitoring-ui/bun.lock create mode 100644 infrastructure/monitoring-ui/next.config.ts create mode 100644 infrastructure/monitoring-ui/package.json create mode 100644 infrastructure/monitoring-ui/postcss.config.js create mode 100644 infrastructure/monitoring-ui/public/.gitkeep create mode 100644 infrastructure/monitoring-ui/railway.json create mode 100644 infrastructure/monitoring-ui/src/app/actions.ts create mode 100644 infrastructure/monitoring-ui/src/app/api/logs/[slug]/route.ts create mode 100644 infrastructure/monitoring-ui/src/app/globals.css create mode 100644 infrastructure/monitoring-ui/src/app/healthz/route.ts create mode 100644 infrastructure/monitoring-ui/src/app/layout.tsx create mode 100644 infrastructure/monitoring-ui/src/app/page.tsx create mode 100644 infrastructure/monitoring-ui/src/components/action-buttons.tsx create mode 100644 infrastructure/monitoring-ui/src/components/logs-modal.tsx create mode 100644 infrastructure/monitoring-ui/src/lib/bench-state.ts create mode 100644 infrastructure/monitoring-ui/src/lib/github.ts create mode 100644 infrastructure/monitoring-ui/src/lib/promote.ts create mode 100644 infrastructure/monitoring-ui/src/lib/registry.ts create mode 100644 infrastructure/monitoring-ui/src/middleware.ts create mode 100644 infrastructure/monitoring-ui/tailwind.config.ts create mode 100644 infrastructure/monitoring-ui/tsconfig.json create mode 100644 infrastructure/monitoring/.env.example create mode 100644 infrastructure/monitoring/README.md create mode 100644 infrastructure/monitoring/alertmanager/Dockerfile create mode 100644 infrastructure/monitoring/alertmanager/alertmanager.yml.tmpl create mode 100755 infrastructure/monitoring/alertmanager/entrypoint.sh create mode 100644 infrastructure/monitoring/grafana/Dockerfile create mode 100644 infrastructure/monitoring/grafana/dashboards/disabled/mobula_pulse_vs_fasttrade.json create mode 100644 infrastructure/monitoring/grafana/dashboards/disabled/pulse_vs_codex.json create mode 100644 infrastructure/monitoring/grafana/dashboards/disabled/quote_api_latency.json create mode 100644 infrastructure/monitoring/grafana/dashboards/head_lag.json create mode 100644 infrastructure/monitoring/grafana/dashboards/metadata_coverage.json create mode 100755 infrastructure/monitoring/grafana/grafana-entrypoint.sh create mode 100644 infrastructure/monitoring/grafana/provisioning/dashboards/dashboard.yml create mode 100644 infrastructure/monitoring/grafana/provisioning/datasources/prometheus.yml create mode 100644 infrastructure/monitoring/prom-gateway/Caddyfile create mode 100644 infrastructure/monitoring/prom-gateway/Dockerfile create mode 100644 infrastructure/monitoring/prom-gateway/README.md create mode 100644 infrastructure/monitoring/prom-gateway/railway.toml create mode 100644 infrastructure/monitoring/prometheus/Dockerfile create mode 100644 infrastructure/monitoring/prometheus/alert_rules.yml create mode 100755 infrastructure/monitoring/prometheus/entrypoint.sh create mode 100644 infrastructure/monitoring/prometheus/prometheus.staging.yml create mode 100644 infrastructure/monitoring/prometheus/prometheus.yml create mode 100644 infrastructure/monitoring/prometheus/recording_rules/ocb_evm_quote_latency.yml create mode 100644 infrastructure/monitoring/prometheus/recording_rules/ocb_gas_estimation.yml create mode 100644 infrastructure/monitoring/prometheus/recording_rules/ocb_l2_block_time.yml create mode 100644 infrastructure/monitoring/prometheus/recording_rules/ocb_pm_rate_limits.yml create mode 100644 infrastructure/monitoring/prometheus/recording_rules/ocb_rpc_capabilities.yml create mode 100644 infrastructure/monitoring/prometheus/recording_rules/ocb_solana_dex_quote_latency.yml create mode 100644 infrastructure/prom-admin/.env.example create mode 100644 infrastructure/prom-admin/.gitignore create mode 100644 infrastructure/prom-admin/Dockerfile create mode 100644 infrastructure/prom-admin/README.md create mode 100644 infrastructure/prom-admin/app/api/chart/route.ts create mode 100644 infrastructure/prom-admin/app/api/delete/route.ts create mode 100644 infrastructure/prom-admin/app/api/labels/route.ts create mode 100644 infrastructure/prom-admin/app/api/metrics/route.ts create mode 100644 infrastructure/prom-admin/app/api/preview/route.ts create mode 100644 infrastructure/prom-admin/app/api/smart-clean/route.ts create mode 100644 infrastructure/prom-admin/app/health/route.ts create mode 100644 infrastructure/prom-admin/app/layout.tsx create mode 100644 infrastructure/prom-admin/app/lib/audit.ts create mode 100644 infrastructure/prom-admin/app/lib/prom.ts create mode 100644 infrastructure/prom-admin/app/lib/rate-limit.ts create mode 100644 infrastructure/prom-admin/app/lib/validate.ts create mode 100644 infrastructure/prom-admin/app/page.tsx create mode 100644 infrastructure/prom-admin/middleware.ts create mode 100644 infrastructure/prom-admin/next.config.ts create mode 100644 infrastructure/prom-admin/package-lock.json create mode 100644 infrastructure/prom-admin/package.json create mode 100644 infrastructure/prom-admin/railway.toml create mode 100644 infrastructure/prom-admin/tsconfig.json diff --git a/harnesses/bridge-monitor/.env.example b/harnesses/bridge-monitor/.env.example new file mode 100644 index 00000000..25008560 --- /dev/null +++ b/harnesses/bridge-monitor/.env.example @@ -0,0 +1,54 @@ +# Bridge API Keys +RELAY_API_KEY= +MOBULA_API_KEY=your_mobula_api_key +DEBRIDGE_API_KEY= +LIFI_API_KEY= + +# Near Intents 1Click API. Optional. JWT partner key (Distribution Channel) +# obtained from https://docs.near-intents.org/integration/distribution-channels. +# Without it the harness runs in anonymous mode (still works but adds ~10 bps +# appFee and lower solver-bus priority). +NEARINTENTS_API_KEY= + +# Wallet Configuration (for execution mode) +# EVM wallet (Base + Arbitrum) +WALLET_EVM_PRIVATE_KEY=0x... +WALLET_EVM_ADDRESS=0x... + +# Solana wallet +WALLET_SOL_PRIVATE_KEY=base58_private_key +WALLET_SOL_ADDRESS=solana_address + +# Execution Mode +# - dry-run: simulate everything, no real TX (default, safe) +# - single-test: run ONE real TX per route then exit (for validation) +# - production: full execution loop with scheduler +EXECUTION_MODE=dry-run + +# Test amount override (for single-test mode, default $1) +TEST_AMOUNT_USD=1.0 + +# Execution Frequencies (Go duration format: 1h, 24h, 168h) +# Default: $5 daily, $50 every 3.5 days, $500 every 15 days +FREQ_5_USD=24h +FREQ_50_USD=84h +FREQ_500_USD=360h + +# Debridge execution (disabled by default - too expensive) +ENABLE_DEBRIDGE_EXEC=false + +# Max daily spending in USD (safety cap) +MAX_DAILY_SPEND_USD=10.0 + +# Simulate balances for testing (uses fake data) +SIMULATE_BALANCES=false + +# Monitoring Region (for metrics labels) +MONITOR_REGION=railway-prod + +# Grafana Admin Password +GF_SECURITY_ADMIN_PASSWORD=admin + +# Slack Notifications (optional) +# Get webhook URL from: Slack App > Incoming Webhooks +SLACK_WEBHOOK_URL=https://hooks.slack.com/services/XXX/YYY/ZZZ diff --git a/harnesses/bridge-monitor/.gitignore b/harnesses/bridge-monitor/.gitignore index 13b984a0..091b86f3 100644 --- a/harnesses/bridge-monitor/.gitignore +++ b/harnesses/bridge-monitor/.gitignore @@ -4,4 +4,4 @@ bin/ prometheus_data/ grafana_data/ .DS_Store -monitor +/monitor diff --git a/harnesses/bridge-monitor/cmd/monitor/balance.go b/harnesses/bridge-monitor/cmd/monitor/balance.go new file mode 100644 index 00000000..570a38fa --- /dev/null +++ b/harnesses/bridge-monitor/cmd/monitor/balance.go @@ -0,0 +1,294 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "strings" + "time" +) + +// BalanceChecker fetches wallet balances from Mobula API +type BalanceChecker struct { + client *http.Client + apiKey string + evmAddress string + solanaAddress string +} + +// MobulaWalletResponse represents the Mobula wallet API response +type MobulaWalletResponse struct { + Data struct { + TotalWalletBalance float64 `json:"total_wallet_balance"` + Assets []struct { + Asset struct { + Name string `json:"name"` + Symbol string `json:"symbol"` + Contracts []string `json:"contracts"` + Blockchains []string `json:"blockchains"` + } `json:"asset"` + TokenBalance float64 `json:"token_balance"` + EstimatedBalance float64 `json:"estimated_balance"` + CrossChainBalances map[string]struct { + Balance float64 `json:"balance"` + ChainId string `json:"chainId"` + } `json:"cross_chain_balances"` + } `json:"assets"` + } `json:"data"` +} + +// NewBalanceChecker creates a new balance checker +func NewBalanceChecker(apiKey, evmAddress, solanaAddress string) *BalanceChecker { + return &BalanceChecker{ + client: &http.Client{Timeout: 30 * time.Second}, + apiKey: apiKey, + evmAddress: evmAddress, + solanaAddress: solanaAddress, + } +} + +// GetAllBalances fetches balances for all configured wallets +// Returns map[chain][token] = balance_usd +func (bc *BalanceChecker) GetAllBalances() (map[string]map[string]float64, error) { + result := make(map[string]map[string]float64) + + // Initialize chains + result["Solana"] = make(map[string]float64) + result["Base"] = make(map[string]float64) + result["Arbitrum"] = make(map[string]float64) + + // Fetch Solana balances + if bc.solanaAddress != "" { + solBalances, err := bc.fetchWalletBalance(bc.solanaAddress) + if err != nil { + log.Printf("⚠️ Failed to fetch Solana balances: %v", err) + } else { + indexAssets(result, solBalances, "Solana") + } + } + + // Fetch EVM balances (Base) + if bc.evmAddress != "" { + // Base + baseBalances, err := bc.fetchWalletBalanceByChain(bc.evmAddress, "base") + if err != nil { + log.Printf("⚠️ Failed to fetch Base balances: %v", err) + } else { + indexAssets(result, baseBalances, "Base") + } + + // Arbitrum + arbBalances, err := bc.fetchWalletBalanceByChain(bc.evmAddress, "arbitrum") + if err != nil { + log.Printf("⚠️ Failed to fetch Arbitrum balances: %v", err) + } else { + indexAssets(result, arbBalances, "Arbitrum") + } + } + + return result, nil +} + +// indexAssets stores balances in the result map, keyed by BOTH symbol AND contract address (lowercase). +// This handles cases where Mobula's DB uses a different symbol than our code (e.g. USDT0 vs USDT). +func indexAssets(result map[string]map[string]float64, resp *MobulaWalletResponse, targetChain string) { + for _, asset := range resp.Data.Assets { + chainBal, ok := asset.CrossChainBalances[targetChain] + if !ok || chainBal.Balance <= 0 { + continue + } + + // Compute USD balance on this specific chain (pro-rata from total) + usdBalance := asset.EstimatedBalance + if asset.TokenBalance > 0 && chainBal.Balance != asset.TokenBalance { + usdBalance = (chainBal.Balance / asset.TokenBalance) * asset.EstimatedBalance + } + + // Store by symbol (backward compat) + result[targetChain][asset.Asset.Symbol] = usdBalance + + // Store by contract address (lowercase) - robust lookup by route.FromToken + // Each asset's Contracts[] is parallel to Blockchains[]; find the entry for targetChain + for i, bc := range asset.Asset.Blockchains { + if strings.EqualFold(bc, targetChain) && i < len(asset.Asset.Contracts) { + addr := strings.ToLower(asset.Asset.Contracts[i]) + result[targetChain][addr] = usdBalance + break + } + } + } +} + +// fetchWalletBalance fetches balance for a single wallet (all chains) +func (bc *BalanceChecker) fetchWalletBalance(address string) (*MobulaWalletResponse, error) { + url := fmt.Sprintf("https://api.mobula.io/api/1/wallet/portfolio?wallet=%s", address) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + if bc.apiKey != "" { + req.Header.Set("Authorization", bc.apiKey) + } + + resp, err := bc.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(body)) + } + + var result MobulaWalletResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("JSON parse error: %w (body: %s)", err, string(body)[:min(200, len(body))]) + } + + return &result, nil +} + +// fetchWalletBalanceByChain fetches balance for a specific chain +func (bc *BalanceChecker) fetchWalletBalanceByChain(address, chain string) (*MobulaWalletResponse, error) { + url := fmt.Sprintf("https://api.mobula.io/api/1/wallet/portfolio?wallet=%s&blockchains=%s", address, chain) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + if bc.apiKey != "" { + req.Header.Set("Authorization", bc.apiKey) + } + + resp, err := bc.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(body)) + } + + var result MobulaWalletResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("JSON parse error: %w", err) + } + + return &result, nil +} + +// min returns the minimum of two ints +func min(a, b int) int { + if a < b { + return a + } + return b +} + +// CheckSufficientFunds checks if we have enough funds for a test +func (bc *BalanceChecker) CheckSufficientFunds(chain, token string, requiredUSD float64) (bool, float64, error) { + balances, err := bc.GetAllBalances() + if err != nil { + return false, 0, err + } + + available := balances[chain][token] + return available >= requiredUSD, available, nil +} + +// PrintBalances logs current balances +func (bc *BalanceChecker) PrintBalances() { + balances, err := bc.GetAllBalances() + if err != nil { + log.Printf("❌ Failed to fetch balances: %v", err) + return + } + + log.Println("💰 Current Balances:") + totalUSD := 0.0 + + for chain, tokens := range balances { + for token, balance := range tokens { + if balance > 0.01 { // Only show non-dust + log.Printf(" %s/%s: $%.2f", chain, token, balance) + totalUSD += balance + } + } + } + + log.Printf(" ────────────────") + log.Printf(" Total: $%.2f", totalUSD) +} + +// ExportBalancesToMetrics refreshes the wallet_balance_usd gauges. +// Skips contract-address keys (indexAssets writes both symbol + address — only symbols +// are useful labels for dashboards/alerts). +func (bc *BalanceChecker) ExportBalancesToMetrics(region string) error { + balances, err := bc.GetAllBalances() + if err != nil { + return err + } + + for chain, tokens := range balances { + for token, usd := range tokens { + // Skip contract-address keys (0x... or >12 char non-uppercase). + if strings.HasPrefix(token, "0x") || len(token) > 12 { + continue + } + walletBalanceUSD.WithLabelValues(chain, token, region).Set(usd) + } + } + walletBalanceLastUpdate.SetToCurrentTime() + return nil +} + +// GetTotalBalanceUSD returns total portfolio value +func (bc *BalanceChecker) GetTotalBalanceUSD() (float64, error) { + balances, err := bc.GetAllBalances() + if err != nil { + return 0, err + } + + total := 0.0 + for _, tokens := range balances { + for _, balance := range tokens { + total += balance + } + } + + return total, nil +} + +// SimulateBalances returns fake balances for dry-run mode +func SimulateBalances() map[string]map[string]float64 { + // Check if we should simulate + if os.Getenv("SIMULATE_BALANCES") != "true" { + return nil + } + + log.Println("🧪 Using simulated balances (SIMULATE_BALANCES=true)") + return map[string]map[string]float64{ + "Solana": { + "SOL": 40.0, + "USDC": 350.0, + "TRUMP": 110.0, + }, + "Base": { + "ETH": 60.0, + "USDC": 0.0, // Will receive from tests + }, + "Arbitrum": { + "ETH": 40.0, + "USDT": 0.0, // Will receive from tests + }, + } +} diff --git a/harnesses/bridge-monitor/cmd/monitor/config.go b/harnesses/bridge-monitor/cmd/monitor/config.go new file mode 100644 index 00000000..573c6cb3 --- /dev/null +++ b/harnesses/bridge-monitor/cmd/monitor/config.go @@ -0,0 +1,243 @@ +package main + +import ( + "bufio" + "fmt" + "log" + "os" + "strconv" + "strings" + "time" +) + +type Config struct { + // API Keys + RelayAPIKey string + MobulaAPIKey string + DebridgeAPIKey string + LiFiAPIKey string + NearIntentsAPIKey string + + // Wallet Configuration + WalletEVMPrivateKey string + WalletEVMAddress string + WalletSOLPrivateKey string + WalletSOLAddress string + + // Execution Configuration + ExecutionMode string // "dry-run", "single-test", "production" + Freq5USD time.Duration // Frequency for $5 tests + Freq50USD time.Duration // Frequency for $50 tests + Freq300USD time.Duration // Frequency for $300 tests + EnableDebridge bool // Execute Debridge (expensive) + MaxDailySpendUSD float64 // Safety cap + TestAmountUSD float64 // Override test amount (for testing with small amounts) + + // General + MonitorRegion string + SimulateBalances bool + + // Notifications + SlackWebhookURL string +} + +// parseDuration parses a duration string, returns default if invalid +func parseDuration(s string, defaultVal time.Duration) time.Duration { + if s == "" { + return defaultVal + } + d, err := time.ParseDuration(s) + if err != nil { + return defaultVal + } + return d +} + +// parseFloat parses a float string, returns default if invalid +func parseFloat(s string, defaultVal float64) float64 { + if s == "" { + return defaultVal + } + f, err := strconv.ParseFloat(s, 64) + if err != nil { + return defaultVal + } + return f +} + +func loadEnv() (*Config, error) { + config := &Config{} + + // Load from environment variables first (for production/Railway) + config.RelayAPIKey = strings.TrimSpace(os.Getenv("RELAY_API_KEY")) + config.MobulaAPIKey = strings.TrimSpace(os.Getenv("MOBULA_API_KEY")) + config.DebridgeAPIKey = strings.TrimSpace(os.Getenv("DEBRIDGE_API_KEY")) + config.LiFiAPIKey = strings.TrimSpace(os.Getenv("LIFI_API_KEY")) + config.NearIntentsAPIKey = strings.TrimSpace(os.Getenv("NEARINTENTS_API_KEY")) + + // Wallet configuration + config.WalletEVMPrivateKey = strings.TrimSpace(os.Getenv("WALLET_EVM_PRIVATE_KEY")) + config.WalletEVMAddress = strings.TrimSpace(os.Getenv("WALLET_EVM_ADDRESS")) + config.WalletSOLPrivateKey = strings.TrimSpace(os.Getenv("WALLET_SOL_PRIVATE_KEY")) + config.WalletSOLAddress = strings.TrimSpace(os.Getenv("WALLET_SOL_ADDRESS")) + + // Execution configuration + config.ExecutionMode = strings.TrimSpace(os.Getenv("EXECUTION_MODE")) + if config.ExecutionMode == "" { + config.ExecutionMode = "dry-run" // Safe default + } + + // Frequencies (default: $5 daily, $50 2x/week, $300 2x/month) + config.Freq5USD = parseDuration(os.Getenv("FREQ_5_USD"), 24*time.Hour) + config.Freq50USD = parseDuration(os.Getenv("FREQ_50_USD"), 84*time.Hour) // ~3.5 days + config.Freq300USD = parseDuration(os.Getenv("FREQ_300_USD"), 168*time.Hour) // 7 days (weekly) + + // Debridge execution (default: disabled, too expensive) + config.EnableDebridge = os.Getenv("ENABLE_DEBRIDGE_EXEC") == "true" + + // Max daily spend (default: $10/day for safety) + config.MaxDailySpendUSD = parseFloat(os.Getenv("MAX_DAILY_SPEND_USD"), 10.0) + + // Test amount override (0 = use default amounts) + config.TestAmountUSD = parseFloat(os.Getenv("TEST_AMOUNT_USD"), 0) + + // General + config.MonitorRegion = strings.TrimSpace(os.Getenv("MONITOR_REGION")) + if config.MonitorRegion == "" { + config.MonitorRegion = "unknown" + } + + config.SimulateBalances = os.Getenv("SIMULATE_BALANCES") == "true" + + // Slack notifications + config.SlackWebhookURL = strings.TrimSpace(os.Getenv("SLACK_WEBHOOK_URL")) + + // Also try .env file for any missing values (local development) + file, err := os.Open(".env") + if err != nil { + // No .env file is OK - services will just be skipped + return config, nil + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + + key, value := strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) + switch key { + case "RELAY_API_KEY": + if config.RelayAPIKey == "" { + config.RelayAPIKey = value + } + case "MOBULA_API_KEY": + if config.MobulaAPIKey == "" { + config.MobulaAPIKey = value + } + case "DEBRIDGE_API_KEY": + if config.DebridgeAPIKey == "" { + config.DebridgeAPIKey = value + } + case "LIFI_API_KEY": + if config.LiFiAPIKey == "" { + config.LiFiAPIKey = value + } + case "NEARINTENTS_API_KEY": + if config.NearIntentsAPIKey == "" { + config.NearIntentsAPIKey = value + } + case "WALLET_EVM_PRIVATE_KEY": + if config.WalletEVMPrivateKey == "" { + config.WalletEVMPrivateKey = value + } + case "WALLET_EVM_ADDRESS": + if config.WalletEVMAddress == "" { + config.WalletEVMAddress = value + } + case "WALLET_SOL_PRIVATE_KEY": + if config.WalletSOLPrivateKey == "" { + config.WalletSOLPrivateKey = value + } + case "WALLET_SOL_ADDRESS": + if config.WalletSOLAddress == "" { + config.WalletSOLAddress = value + } + case "MONITOR_REGION": + if config.MonitorRegion == "" || config.MonitorRegion == "unknown" { + config.MonitorRegion = value + } + case "EXECUTION_MODE": + if config.ExecutionMode == "" || config.ExecutionMode == "dry-run" { + config.ExecutionMode = value + } + case "SLACK_WEBHOOK_URL": + if config.SlackWebhookURL == "" { + config.SlackWebhookURL = value + } + case "MAX_DAILY_SPEND_USD": + if config.MaxDailySpendUSD == 10.0 { // default value + config.MaxDailySpendUSD = parseFloat(value, 10.0) + } + case "TEST_AMOUNT_USD": + if config.TestAmountUSD == 0 { + config.TestAmountUSD = parseFloat(value, 0) + } + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("error reading .env file: %w", err) + } + + return config, nil +} + +// LogConfig prints the configuration (without sensitive data) +func (c *Config) LogConfig() { + log.Println("⚙️ Configuration:") + log.Printf(" Mobula API Key: %s", maskKey(c.MobulaAPIKey)) + log.Printf(" Relay API Key: %s", maskKey(c.RelayAPIKey)) + log.Printf(" Li.Fi API Key: %s", maskKey(c.LiFiAPIKey)) + log.Printf(" Debridge API Key: %s", maskKey(c.DebridgeAPIKey)) + log.Printf(" EVM Address: %s", c.WalletEVMAddress) + log.Printf(" Solana Address: %s", c.WalletSOLAddress) + log.Printf(" EVM Private Key: %s", maskKey(c.WalletEVMPrivateKey)) + log.Printf(" Solana Private Key: %s", maskKey(c.WalletSOLPrivateKey)) + log.Printf(" Execution Mode: %s", c.ExecutionMode) + log.Printf(" $5 Frequency: %v", c.Freq5USD) + log.Printf(" $50 Frequency: %v", c.Freq50USD) + log.Printf(" $300 Frequency: %v", c.Freq300USD) + log.Printf(" Debridge Execution: %v", c.EnableDebridge) + log.Printf(" Max Daily Spend: $%.2f", c.MaxDailySpendUSD) + if c.TestAmountUSD > 0 { + log.Printf(" Test Amount Override: $%.2f", c.TestAmountUSD) + } + log.Printf(" Region: %s", c.MonitorRegion) + log.Printf(" Slack Notifications: %s", boolToEnabled(c.SlackWebhookURL != "")) +} + +func boolToEnabled(b bool) string { + if b { + return "enabled" + } + return "disabled" +} + +// maskKey masks a key for logging (shows first 4 and last 4 chars) +func maskKey(key string) string { + if key == "" { + return "(not set)" + } + if len(key) <= 8 { + return "****" + } + return key[:4] + "..." + key[len(key)-4:] +} diff --git a/harnesses/bridge-monitor/cmd/monitor/cycle_sim.go b/harnesses/bridge-monitor/cmd/monitor/cycle_sim.go new file mode 100644 index 00000000..64831192 --- /dev/null +++ b/harnesses/bridge-monitor/cmd/monitor/cycle_sim.go @@ -0,0 +1,77 @@ +package main + +import "fmt" + +// CycleSimulation is the verdict on whether a full triangle cycle at a given tier +// can complete, given current wallet balances. If not viable, it tells you which +// leg is the bottleneck and how much it's short, and where to send a refill. +type CycleSimulation struct { + Viable bool + Tier float64 + BlockLeg string // e.g. "R1 Sol USDC" + Needed float64 // USD required at that point in the cycle + Available float64 // USD actually available (incl. upstream route inflow) + Reason string // one-line human-readable summary + + // Refill target — what the operator needs to send to unblock. Empty when Viable. + RefillChain string // "Solana" | "Base" | "Arbitrum" + RefillToken string // "USDC" | "USDT" + RefillUSD float64 +} + +// SimulateTriangleCycle walks the triangle R1→R2→R3 for the SEQUENTIAL PER-BRIDGE +// orchestration: each bridge does its own full Sol→Base→Arb→Sol triangle before +// the next bridge starts. Consequence: each leg only needs 1×tier at peak (not 3×), +// because the preceding route settled ~1×tier onto it before it has to source. +// +// Conservative fee buffer: 2% cumulative per bridge's 3-hop round-trip (0.98 factor). +func SimulateTriangleCycle(balances map[string]map[string]float64, tier float64) CycleSimulation { + const netFactor = 0.98 + + solUSDC := balances["Solana"]["USDC"] + baseUSDC := balances["Base"]["USDC"] + arbUSDT := balances["Arbitrum"]["USDT0"] + + // Per-bridge leg requirement is 1×tier (not 3×): one TX at a time, settlement + // replenishes the next leg before it has to source. + need := tier + + sim := CycleSimulation{Tier: tier} + + // R1: Sol USDC → Base USDC. No preceding inflow on first bridge's iteration. + if solUSDC < need { + sim.BlockLeg = "R1 Sol USDC" + sim.Needed = need + sim.Available = solUSDC + sim.Reason = fmt.Sprintf("R1 Sol→Base blocked: need $%.2f USDC on Solana, have $%.2f", need, solUSDC) + sim.RefillChain, sim.RefillToken, sim.RefillUSD = "Solana", "USDC", need-solUSDC + return sim + } + + // R2: Base USDC → Arb USDT. R1 of current bridge deposited ~tier × 0.98 on Base. + baseEff := baseUSDC + need*netFactor + if baseEff < need { + sim.BlockLeg = "R2 Base USDC" + sim.Needed = need + sim.Available = baseEff + sim.Reason = fmt.Sprintf("R2 Base→Arb blocked: need $%.2f USDC on Base (incl. R1 inflow ≈$%.2f), effective $%.2f", + need, need*netFactor, baseEff) + sim.RefillChain, sim.RefillToken, sim.RefillUSD = "Base", "USDC", need-baseEff + return sim + } + + // R3: Arb USDT → Sol USDC. R2 of current bridge deposited ~tier × 0.98 on Arb. + arbEff := arbUSDT + need*netFactor + if arbEff < need { + sim.BlockLeg = "R3 Arb USDT" + sim.Needed = need + sim.Available = arbEff + sim.Reason = fmt.Sprintf("R3 Arb→Sol blocked: need $%.2f USDT on Arbitrum (incl. R2 inflow ≈$%.2f), effective $%.2f", + need, need*netFactor, arbEff) + sim.RefillChain, sim.RefillToken, sim.RefillUSD = "Arbitrum", "USDT", need-arbEff + return sim + } + + sim.Viable = true + return sim +} diff --git a/harnesses/bridge-monitor/cmd/monitor/debridge_bridge.go b/harnesses/bridge-monitor/cmd/monitor/debridge_bridge.go new file mode 100644 index 00000000..2c15fd65 --- /dev/null +++ b/harnesses/bridge-monitor/cmd/monitor/debridge_bridge.go @@ -0,0 +1,162 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strconv" + "strings" + "time" +) + +type DebridgeBridge struct { + client *http.Client +} + +func NewDebridgeBridge() *DebridgeBridge { + return &DebridgeBridge{client: &http.Client{Timeout: 45 * time.Second}} +} + +// Chain ID mapping for Debridge +func debridgeChainID(chain string) int64 { + switch strings.ToLower(chain) { + case "solana": + return 7565164 + case "base": + return 8453 + case "arbitrum": + return 42161 + } + return 0 +} + +// Debridge protocol charges a fixed amount in native tokens that we convert to USD +// using live spot prices (5min cache). Hardcoded fallbacks are intentionally +// conservative so an API outage doesn't make Debridge look artificially cheap. +// Solana: 0.015 SOL native fix fee +// EVM: 0.001 ETH native fix fee +func debridgeFixFeeUSD(fromChain string) float64 { + switch strings.ToLower(fromChain) { + case "solana": + return 0.015 * TokenPriceUSD("SOL", 86.0) + default: + return 0.001 * TokenPriceUSD("ETH", 2300.0) + } +} + +type DebridgeQuoteResponse struct { + Estimation struct { + SrcChainTokenIn struct { + ApproximateUsdValue float64 `json:"approximateUsdValue"` + OriginApproximateUsdValue float64 `json:"originApproximateUsdValue"` + } `json:"srcChainTokenIn"` + DstChainTokenOut struct { + ApproximateUsdValue float64 `json:"approximateUsdValue"` + } `json:"dstChainTokenOut"` + } `json:"estimation"` + FixFee string `json:"fixFee"` + ProtocolFeeApproximateUsdValue float64 `json:"protocolFeeApproximateUsdValue"` + Order struct { + ApproximateFulfillmentDelay int64 `json:"approximateFulfillmentDelay"` + } `json:"order"` + ErrorMessage string `json:"errorMessage,omitempty"` + ErrorCode int `json:"errorCode,omitempty"` +} + +func (d *DebridgeBridge) GetQuote(route TestRoute, rawAmount string) (*DebridgeQuoteResponse, time.Duration, error) { + start := time.Now() + url := fmt.Sprintf( + "https://dln.debridge.finance/v1.0/dln/order/quote?srcChainId=%d&srcChainTokenIn=%s&srcChainTokenInAmount=%s&dstChainId=%d&dstChainTokenOut=%s&dstChainTokenOutAmount=auto&prependOperatingExpenses=true", + debridgeChainID(route.FromChain), route.FromToken, rawAmount, + debridgeChainID(route.ToChain), route.ToToken, + ) + + // 2026-06-11: dln.debridge.finance moved behind a Cloudflare challenge + // that 403s non-browser user agents (Go-http-client). Browser-grade + // headers pass the non-interactive check; verified 200 in ~150ms. + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, time.Since(start), fmt.Errorf("debridge request: %w", err) + } + req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36") + req.Header.Set("Accept", "application/json") + resp, err := d.client.Do(req) + if err != nil { + return nil, time.Since(start), fmt.Errorf("debridge request: %w", err) + } + defer resp.Body.Close() + + raw, _ := io.ReadAll(resp.Body) + latency := time.Since(start) + if resp.StatusCode != http.StatusOK { + return nil, latency, fmt.Errorf("debridge %d: %s", resp.StatusCode, string(raw)) + } + + var out DebridgeQuoteResponse + if err := json.Unmarshal(raw, &out); err != nil { + return nil, latency, fmt.Errorf("debridge decode: %w", err) + } + if out.ErrorMessage != "" { + return nil, latency, fmt.Errorf("debridge error: %s", out.ErrorMessage) + } + return &out, latency, nil +} + +func (d *DebridgeBridge) TestRoute(route TestRoute, amount, amountUsd float64, rawUnits string, region string) { + amountStr := strconv.FormatFloat(amountUsd, 'f', 0, 64) + labels := []string{"debridge", route.FromChain, route.ToChain, route.FromToken, route.ToToken, amountStr, region, route.ToChain} + + quote, quoteLatency, err := d.GetQuote(route, rawUnits) + + if err != nil { + log.Printf("[DEBRIDGE][%s][%.0f USD] ❌ %v", route.Name, amountUsd, err) + bridgeErrors.WithLabelValues(append(labels, "quote_failed")...).Inc() + bridgeQuoteSuccess.WithLabelValues(labels...).Set(0) + return + } + + // Latency is only meaningful for quotes that returned a usable route + // (the published methodology measures exactly that). Fast failures, e.g. + // Cloudflare 403s answered in 30ms, must not enter the histogram: they + // made deBridge look 15x faster the moment its API started rejecting us. + bridgeQuoteLatency.WithLabelValues(labels...).Observe(float64(quoteLatency.Milliseconds())) + bridgeQuoteSuccess.WithLabelValues(labels...).Set(1) + + // Debridge returns input (with opEx ajusté) and output + // costUsd = (user_paid_input - user_received_output) + fixFee_native_usd + // user_paid_input: srcChainTokenIn.approximateUsdValue (includes opEx) + // user_received: dstChainTokenOut.approximateUsdValue + inUsd := quote.Estimation.SrcChainTokenIn.ApproximateUsdValue + outUsd := quote.Estimation.DstChainTokenOut.ApproximateUsdValue + fixFee := debridgeFixFeeUSD(route.FromChain) + protocolFee := quote.ProtocolFeeApproximateUsdValue + + costUsd := (inUsd - outUsd) + fixFee + if costUsd < 0 { + costUsd = 0 + } + costPct := 0.0 + if amountUsd > 0 { + costPct = (costUsd / amountUsd) * 100 + } + slippageUsd := costUsd - fixFee - protocolFee + if slippageUsd < 0 { + slippageUsd = 0 + } + + bridgeFeesUSD.WithLabelValues(labels...).Set(fixFee + protocolFee) + bridgeFeesPercent.WithLabelValues(labels...).Set(((fixFee + protocolFee) / amountUsd) * 100) + bridgeCostUSD.WithLabelValues(labels...).Set(costUsd) + bridgeCostPercent.WithLabelValues(labels...).Set(costPct) + bridgeSlippageUSD.WithLabelValues(labels...).Set(slippageUsd) + bridgeGasUSD.WithLabelValues(labels...).Set(0) // included in fixFee + bridgeFixFeeUSD.WithLabelValues(labels...).Set(fixFee) + bridgeOutputUSD.WithLabelValues(labels...).Set(outUsd) + bridgeEstimatedTimeMs.WithLabelValues(labels...).Set(float64(quote.Order.ApproximateFulfillmentDelay * 1000)) + + log.Printf("[DEBRIDGE][%s][%.0f USD] ✅ Quote: %dms | Cost: $%.4f (%.3f%%) | Est: %ds", + route.Name, amountUsd, quoteLatency.Milliseconds(), + costUsd, costPct, quote.Order.ApproximateFulfillmentDelay) +} diff --git a/harnesses/bridge-monitor/cmd/monitor/executor.go b/harnesses/bridge-monitor/cmd/monitor/executor.go new file mode 100644 index 00000000..94ef212b --- /dev/null +++ b/harnesses/bridge-monitor/cmd/monitor/executor.go @@ -0,0 +1,1128 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "strconv" + "strings" + "time" +) + +// ExecutionMode controls whether we actually broadcast transactions +type ExecutionMode string + +const ( + ModeDryRun ExecutionMode = "dry-run" // Simulate everything, no real TX + ModeSingleTest ExecutionMode = "single-test" // One real TX to validate + ModeProduction ExecutionMode = "production" // Full execution loop +) + +// ExecutionConfig holds the execution loop configuration +type ExecutionConfig struct { + Mode ExecutionMode + Freq5USD time.Duration // How often to run $5 tests + Freq50USD time.Duration // How often to run $50 tests + Freq300USD time.Duration // How often to run $300 tests + EnableDebridge bool // Whether to execute Debridge (expensive) + MaxDailySpendUSD float64 // Safety cap on daily spending + DailySpentUSD float64 // Track daily spending + LastResetDay int // Day of month for daily reset +} + +// ExecutionResult holds the result of an execution test +type ExecutionResult struct { + Bridge string + Route TestRoute + AmountUSD float64 + QuoteLatencyMs int64 + ExecutionLatencyMs int64 // Time from broadcast to funds received + E2ELatencyMs int64 // Time from quote start to funds received + Success bool + Reverted bool + Error error + QuoteFeeUSD float64 // Fee from quote + ActualFeeUSD float64 // Actual fee paid (input - output) + TxHash string + DryRun bool + // For Slack notifications + FromChain string + ToChain string + FromToken string + ToToken string + FeesUSD float64 + FeesPercent float64 + CostUSD float64 + OutputUSD float64 // What landed on destination (the fill) +} + +// Executor handles the execution loop +type Executor struct { + config *ExecutionConfig + walletManager *WalletManager + balanceCheck *BalanceChecker + txExecutor *TxExecutor + mobula *MobulaBridge + relay *RelayBridge + lifi *LiFiBridge + debridge *DebridgeBridge + region string + slack *SlackNotifier +} + +// NewExecutor creates a new executor +func NewExecutor( + config *ExecutionConfig, + walletManager *WalletManager, + balanceCheck *BalanceChecker, + mobula *MobulaBridge, + relay *RelayBridge, + lifi *LiFiBridge, + debridge *DebridgeBridge, + region string, + slack *SlackNotifier, +) *Executor { + e := &Executor{ + config: config, + walletManager: walletManager, + balanceCheck: balanceCheck, + mobula: mobula, + relay: relay, + lifi: lifi, + debridge: debridge, + region: region, + slack: slack, + } + + // Initialize TxExecutor if we have private keys + if walletManager != nil && walletManager.HasPrivateKeys() { + dryRun := config.Mode != ModeProduction && config.Mode != ModeSingleTest + mobulaAPIKey := "" + if mobula != nil { + mobulaAPIKey = mobula.APIKey() + } + txExec, err := NewTxExecutor( + walletManager.SolanaPrivateKey, + walletManager.EVMPrivateKey, + mobulaAPIKey, + dryRun, + ) + if err != nil { + log.Printf("⚠️ Failed to initialize TxExecutor: %v", err) + } else { + e.txExecutor = txExec + log.Println("✅ TxExecutor initialized") + } + } + + return e +} + +// RunDryRun simulates the full execution flow without broadcasting +func (e *Executor) RunDryRun(route TestRoute, amountUSD float64) *ExecutionResult { + result := &ExecutionResult{ + Route: route, + AmountUSD: amountUSD, + DryRun: true, + } + + log.Printf("🧪 [DRY-RUN] Testing %s with $%.0f", route.Name, amountUSD) + + // Step 1: Check balances + log.Printf(" 📊 Checking balances...") + balances, err := e.balanceCheck.GetAllBalances() + if err != nil { + log.Printf(" ❌ Balance check failed: %v", err) + result.Error = err + return result + } + + // Log balances + for chain, tokens := range balances { + for token, bal := range tokens { + log.Printf(" %s/%s: $%.2f", chain, token, bal) + } + } + + // Step 2: Verify we have enough funds (lookup by contract address, robust against symbol mismatch) + // 3 bridges execute sequentially, each consuming `amountUSD` from the source leg. + // 5% buffer on top of the total to cover per-bridge fees. + requiredAmount := 3 * amountUSD * 1.05 + available := getAvailableBalance(balances, route) + + if available < requiredAmount { + log.Printf(" ⚠️ Insufficient funds on %s %s: need $%.2f, have $%.2f", route.FromChain, route.FromToken, requiredAmount, available) + result.Error = fmt.Errorf("insufficient funds: need %.2f, have %.2f", requiredAmount, available) + return result + } + log.Printf(" ✅ Sufficient funds: $%.2f available", available) + + // Step 3: Get quotes from all bridges + log.Printf(" 📝 Getting quotes...") + + // Test Mobula + if e.mobula != nil { + e.testBridgeDryRun("mobula", route, amountUSD) + } + + // Test Relay + e.testBridgeDryRun("relay", route, amountUSD) + + // Test Li.Fi + e.testBridgeDryRun("lifi", route, amountUSD) + + // Test Debridge (if enabled) + if e.config.EnableDebridge { + e.testBridgeDryRun("debridge", route, amountUSD) + } + + result.Success = true + return result +} + +// RunReal executes real transactions and measures latency +func (e *Executor) RunReal(route TestRoute, amountUSD float64) []*ExecutionResult { + var results []*ExecutionResult + + log.Printf("💸 [REAL] Executing %s with $%.0f", route.Name, amountUSD) + + // Safety checks + if e.txExecutor == nil { + log.Printf("❌ TxExecutor not initialized - cannot execute") + return results + } + + if !e.txExecutor.CanExecute() { + log.Printf("❌ Cannot execute - missing private keys or in dry-run mode") + return results + } + + // Check daily spending limit + if e.config.DailySpentUSD >= e.config.MaxDailySpendUSD { + msg := fmt.Sprintf("Daily spending limit reached ($%.2f / $%.2f)", e.config.DailySpentUSD, e.config.MaxDailySpendUSD) + log.Printf("⚠️ %s", msg) + if e.slack != nil { + _ = e.slack.NotifyScheduledSkip(route.Name, route.FromChain, route.FromToken, amountUSD, msg) + } + return results + } + + // Check balances + balances, err := e.balanceCheck.GetAllBalances() + if err != nil { + log.Printf("❌ Balance check failed: %v", err) + if e.slack != nil { + _ = e.slack.NotifyScheduledSkip(route.Name, route.FromChain, route.FromToken, amountUSD, + fmt.Sprintf("Balance check failed: %v", err)) + } + return results + } + + // 3 bridges run sequentially per route, each pulling `amountUSD` from the source + // leg. Need 3×amount × 1.05 (fee buffer) at route start — pre-flight already + // simulated the cycle, this is the per-route safety net. + requiredAmount := 3 * amountUSD * 1.05 + available := getAvailableBalance(balances, route) + + if available < requiredAmount { + msg := fmt.Sprintf("Insufficient funds: need $%.2f, have $%.2f", requiredAmount, available) + log.Printf("⚠️ %s on %s %s", msg, route.FromChain, route.FromToken) + if e.slack != nil { + _ = e.slack.NotifyScheduledSkip(route.Name, route.FromChain, route.FromToken, amountUSD, msg) + } + return results + } + + // Calculate raw units - for USDC/USDT amount equals USD, for TRUMP convert + amount := amountUSD + if route.Name == "TRUMP_SOL_BRETT_BASE" { + amount = amountUSD / TokenPriceUSD("TRUMP", 2.55) // live TRUMP price (5min cache) + } + rawUnits := toRawUnits(amount) + + // Execute on each bridge (except Debridge - too expensive) + bridges := []string{"mobula", "relay", "lifi"} + + for _, bridge := range bridges { + result := e.executeOnBridge(bridge, route, amount, amountUSD, rawUnits) + if result != nil { + results = append(results, result) + + // Record metrics + e.recordExecutionMetrics(result) + + // Send Slack notification + if e.slack != nil { + log.Printf(" 📤 Sending Slack notification...") + if err := e.slack.NotifyBridgeExecution(result); err != nil { + log.Printf(" ⚠️ Slack notification failed: %v", err) + } else { + log.Printf(" ✅ Slack notification sent") + } + } + + // Update daily spending + if result.Success { + e.config.DailySpentUSD += result.ActualFeeUSD + } + } + + // Wait between bridges to avoid rate limiting + time.Sleep(2 * time.Second) + } + + return results +} + +// RunBridgeOnRoute executes ONE bridge on ONE route, records metrics, and fires Slack. +// Called by the sequential per-bridge orchestration in main.go so each bridge does its +// own full R1→R2→R3 triangle before the next bridge starts (lower peak capital need +// per leg, cleaner per-bridge round-trip cost). +func (e *Executor) RunBridgeOnRoute(bridge string, route TestRoute, amountUSD float64) *ExecutionResult { + if e.txExecutor == nil || !e.txExecutor.CanExecute() { + return nil + } + if e.config.DailySpentUSD >= e.config.MaxDailySpendUSD { + log.Printf("⚠️ Daily spending limit reached ($%.2f / $%.2f)", e.config.DailySpentUSD, e.config.MaxDailySpendUSD) + return nil + } + + amount := amountUSD + if route.Name == "TRUMP_SOL_BRETT_BASE" { + amount = amountUSD / TokenPriceUSD("TRUMP", 2.55) // live TRUMP price (5min cache) + } + rawUnits := toRawUnits(amount) + + result := e.executeOnBridge(bridge, route, amount, amountUSD, rawUnits) + if result == nil { + return nil + } + + e.recordExecutionMetrics(result) + + if e.slack != nil { + if err := e.slack.NotifyBridgeExecution(result); err != nil { + log.Printf(" ⚠️ Slack notification failed: %v", err) + } + } + + if result.Success { + e.config.DailySpentUSD += result.ActualFeeUSD + } + return result +} + +// executeOnBridge executes a transaction on a specific bridge +func (e *Executor) executeOnBridge(bridge string, route TestRoute, amount, amountUSD float64, rawUnits string) *ExecutionResult { + result := &ExecutionResult{ + Bridge: bridge, + Route: route, + AmountUSD: amountUSD, + } + + log.Printf(" [%s] Executing $%.0f %s...", bridge, amountUSD, route.Name) + + // Capture destination balance BEFORE execution so we can compute realized + // fill (post-fill - pre-fill) instead of trusting the quote's projected + // output. The quote can lie (e.g. Mobula returns "expected $50" while + // minOut is $49.5 — the actual fill is somewhere in between). + receiver := e.walletManager.EVMAddress + if route.ToChain == "Solana" { + receiver = e.walletManager.SolanaAddress + } + preBalanceRaw, preBalErr := e.txExecutor.readDestinationBalance(route.ToChain, route.ToToken, receiver) + if preBalErr != nil { + log.Printf(" ⚠️ pre-execution balance read failed (%v) — falling back to quote-projected fill", preBalErr) + } + + // PHASE 1: Get quote with TX data + quoteStart := time.Now() + var txHash string + var err error + + switch bridge { + case "mobula": + result, txHash, err = e.executeMobula(route, amount, quoteStart) + case "relay": + result, txHash, err = e.executeRelay(route, rawUnits, quoteStart) + case "lifi": + result, txHash, err = e.executeLiFi(route, rawUnits, quoteStart) + } + + // Populate route fields for Slack (do this before error check so failed results have route info) + result.FromChain = route.FromChain + result.ToChain = route.ToChain + result.FromToken = route.FromToken + result.ToToken = route.ToToken + result.AmountUSD = amountUSD + + if err != nil { + log.Printf(" ❌ Execution failed: %v", err) + result.Error = err + return result + } + + result.TxHash = txHash + // If the sub-function flagged a refund/revert, keep Success=false so Slack and + // Prometheus correctly classify it (Reverted takes precedence over Success). + result.Success = !result.Reverted + + // Read the destination balance again to compute the REALIZED fill on-chain. + // Bridge status "filled" sometimes precedes the destination credit by 1-3 + // blocks; pollRealizedFill waits up to 30s for the delta to materialise. + if result.Success && preBalErr == nil { + postBalanceRaw, pollErr := e.txExecutor.pollRealizedFill(route.ToChain, route.ToToken, receiver, preBalanceRaw, 30*time.Second) + if pollErr != nil { + log.Printf(" ⚠️ realized fill not visible within 30s (%v) — keeping quote-projected output", pollErr) + } else if postBalanceRaw != nil { + deltaRaw := rawDelta(postBalanceRaw, preBalanceRaw) + realizedToken := rawToFloat(deltaRaw, destinationTokenDecimals(route)) + realizedUSD := realizedToken * destinationUSDPerToken(route) + log.Printf(" 💰 Realized fill on-chain: %.6f tokens = $%.4f (quote projected $%.4f)", realizedToken, realizedUSD, result.OutputUSD) + result.OutputUSD = realizedUSD + // Recompute fees from realized: amount sent - amount received + realFees := amountUSD - realizedUSD + if realFees < 0 { + realFees = 0 + } + result.ActualFeeUSD = realFees + } + } + + result.FeesUSD = result.ActualFeeUSD + if amountUSD > 0 { + result.FeesPercent = (result.ActualFeeUSD / amountUSD) * 100 + } + result.CostUSD = result.ActualFeeUSD + + status := "✅ Success" + if result.Reverted { + status = "🔄 Reverted" + } + log.Printf(" %s! TX: %s | Quote: %dms | Exec: %dms | E2E: %dms | Fee: $%.4f", + status, + txHash[:16]+"...", + result.QuoteLatencyMs, + result.ExecutionLatencyMs, + result.E2ELatencyMs, + result.ActualFeeUSD, + ) + + return result +} + +// executeMobula handles Mobula bridge execution +func (e *Executor) executeMobula(route TestRoute, amount float64, quoteStart time.Time) (*ExecutionResult, string, error) { + result := &ExecutionResult{Bridge: "mobula", Route: route, AmountUSD: amount} + + // Determine sender address based on source chain + senderAddress := e.walletManager.EVMAddress + receiverAddress := e.walletManager.EVMAddress + if route.FromChain == "Solana" { + senderAddress = e.walletManager.SolanaAddress + } + if route.ToChain == "Solana" { + receiverAddress = e.walletManager.SolanaAddress + } + + log.Printf(" [mobula] Getting quote: %s → %s, amount: %.4f, sender: %s", route.FromChain, route.ToChain, amount, senderAddress[:8]+"...") + + // Get quote with TX + quote, _, err := e.mobula.GetQuote( + route.FromChainAPI, route.FromToken, + route.ToChainAPI, route.ToToken, + senderAddress, receiverAddress, + amount, + ) + if err != nil { + log.Printf(" [mobula] ❌ Quote error: %v", err) + return result, "", fmt.Errorf("quote failed: %w", err) + } + + result.QuoteLatencyMs = time.Since(quoteStart).Milliseconds() + bridgeFeeUSD, _ := strconv.ParseFloat(quote.Data.Fees.TotalFeeUsd, 64) + gasFeeUSD, _ := strconv.ParseFloat(quote.Data.Fees.GasFeeUsd, 64) + result.QuoteFeeUSD = bridgeFeeUSD + gasFeeUSD + if outUsd, err := strconv.ParseFloat(quote.Data.EstimatedAmountOutUsd, 64); err == nil { + result.OutputUSD = outUsd + } + + // Check for approval step in steps array + var approveStepIdx = -1 + for i, step := range quote.Data.Steps { + if step.Type == "approve" { + approveStepIdx = i + break + } + } + hasApprove := approveStepIdx >= 0 + + log.Printf(" [mobula] ✅ Quote received: fee=$%.4f, outputUSD=$%.2f, hasDepositSolana=%v, hasDepositEVM=%v, hasApprove=%v, stepsCount=%d", + result.QuoteFeeUSD, + func() float64 { v, _ := strconv.ParseFloat(quote.Data.EstimatedAmountOutUsd, 64); return v }(), + quote.Data.Deposit.Solana.SerializedTx != "", + quote.Data.Deposit.EVM.To != "", + hasApprove, + len(quote.Data.Steps), + ) + + // Step 1: Send approval TX if required (for ERC-20 tokens on EVM) + if hasApprove { + approveStep := quote.Data.Steps[approveStepIdx] + log.Printf(" [mobula] 📝 Sending ERC-20 approval to=%s", approveStep.Tx.To) + approvalHash, err := e.txExecutor.ExecuteEVMTransaction(route.FromChain, approveStep.Tx.To, approveStep.Tx.Data, approveStep.Tx.Value) + if err != nil { + log.Printf(" [mobula] ❌ Approval TX failed: %v", err) + return result, "", fmt.Errorf("approval failed: %w", err) + } + log.Printf(" [mobula] ✅ Approval TX sent: %s", approvalHash) + + // Wait for approval to confirm + log.Printf(" [mobula] ⏳ Waiting for approval confirmation...") + time.Sleep(5 * time.Second) + + // Verify approval confirmed + success, err := e.txExecutor.CheckEVMTxStatus(route.FromChain, approvalHash) + if err != nil || !success { + log.Printf(" [mobula] ❌ Approval TX failed to confirm") + return result, approvalHash, fmt.Errorf("approval not confirmed") + } + log.Printf(" [mobula] ✅ Approval confirmed") + } + + // Step 2: Execute deposit transaction + execStart := time.Now() + var txHash string + + if quote.Data.Deposit.Solana.SerializedTx != "" { + // Solana source - use deposit.solana.serializedTx + log.Printf(" [mobula] 📤 Broadcasting Solana TX (type=%s, len=%d)", quote.Data.Deposit.Solana.Type, len(quote.Data.Deposit.Solana.SerializedTx)) + txHash, err = e.txExecutor.ExecuteSolanaTransaction(quote.Data.Deposit.Solana.SerializedTx) + } else if quote.Data.Deposit.EVM.To != "" { + // EVM source - use deposit.evm + log.Printf(" [mobula] 📤 Broadcasting EVM TX to=%s, value=%s", quote.Data.Deposit.EVM.To, quote.Data.Deposit.EVM.Value) + txHash, err = e.txExecutor.ExecuteEVMTransaction(route.FromChain, quote.Data.Deposit.EVM.To, quote.Data.Deposit.EVM.Data, quote.Data.Deposit.EVM.Value) + } else if len(quote.Data.Steps) > 0 { + // Use steps array - approval already handled above, find bridgeToken step + for _, step := range quote.Data.Steps { + if step.Type == "bridgeToken" || step.Type != "approve" { + log.Printf(" [mobula] 📤 Broadcasting EVM TX (steps) to=%s, value=%s", step.Tx.To, step.Tx.Value) + txHash, err = e.txExecutor.ExecuteEVMTransaction(route.FromChain, step.Tx.To, step.Tx.Data, step.Tx.Value) + break + } + } + } else { + log.Printf(" [mobula] ❌ No TX data in quote response (deposit.solana=%v, deposit.evm=%v, steps=%d)", + quote.Data.Deposit.Solana.SerializedTx != "", quote.Data.Deposit.EVM.To != "", len(quote.Data.Steps)) + return result, "", fmt.Errorf("no transaction data in quote") + } + + if err != nil { + log.Printf(" [mobula] ❌ Broadcast error: %v", err) + return result, "", fmt.Errorf("broadcast failed: %w", err) + } + + log.Printf(" [mobula] ✅ TX broadcast: %s", txHash) + log.Printf(" [mobula] ⏳ Polling status (timeout: 5min)...") + + // Mobula can take 2-5min to settle an intent even on EVM sources (solver backlog, + // indexer lag, etc.). Use 5min for both Solana and EVM so we don't bail before + // Mobula has a chance to fill or refund. The EVM receipt check below is still + // useful if our TX itself reverted on-chain. + pollTimeout := 5 * time.Minute + + status, err := e.txExecutor.PollMobulaStatus(txHash, pollTimeout) + execEnd := time.Now() + + // For EVM source: if still pending/timeout, check TX receipt directly + if route.FromChain != "Solana" && (err != nil || status == nil || status.Status == "pending") { + log.Printf(" [mobula] 🔍 Checking EVM TX receipt...") + success, receiptErr := e.txExecutor.CheckEVMTxStatus(route.FromChain, txHash) + if receiptErr == nil { + if !success { + log.Printf(" [mobula] ❌ EVM TX reverted!") + result.Reverted = true + result.ExecutionLatencyMs = execEnd.Sub(execStart).Milliseconds() + result.E2ELatencyMs = execEnd.Sub(quoteStart).Milliseconds() + return result, txHash, fmt.Errorf("transaction reverted on-chain") + } + // TX succeeded on-chain but Mobula API not updated yet + log.Printf(" [mobula] ✅ EVM TX confirmed on-chain, bridge pending...") + } + } + + if err != nil { + log.Printf(" [mobula] ❌ Status poll error: %v", err) + return result, txHash, fmt.Errorf("status poll failed: %w", err) + } + + result.ExecutionLatencyMs = execEnd.Sub(execStart).Milliseconds() + result.E2ELatencyMs = execEnd.Sub(quoteStart).Milliseconds() + + log.Printf(" [mobula] 🏁 Final status: %s (exec: %dms, e2e: %dms)", + status.Status, result.ExecutionLatencyMs, result.E2ELatencyMs) + + if status.Status == "filled" || status.Status == "settled" { + result.Success = true + result.ActualFeeUSD = result.QuoteFeeUSD + } else if status.Status == "refunded" { + result.Reverted = true + log.Printf(" [mobula] ⚠️ Transaction was refunded!") + } + + return result, txHash, nil +} + +// executeRelay handles Relay bridge execution +func (e *Executor) executeRelay(route TestRoute, rawUnits string, quoteStart time.Time) (*ExecutionResult, string, error) { + result := &ExecutionResult{Bridge: "relay", Route: route} + + // Determine sender address based on source chain + senderAddress := e.walletManager.EVMAddress + receiverAddress := e.walletManager.EVMAddress + if route.FromChain == "Solana" { + senderAddress = e.walletManager.SolanaAddress + } + if route.ToChain == "Solana" { + receiverAddress = e.walletManager.SolanaAddress + } + + log.Printf(" [relay] Getting quote: %s → %s, rawUnits: %s, sender: %s", route.FromChain, route.ToChain, rawUnits, senderAddress[:8]+"...") + + // Get quote with TX + quote, _, err := e.relay.GetQuote(route, rawUnits, senderAddress, receiverAddress) + if err != nil { + log.Printf(" [relay] ❌ Quote error: %v", err) + return result, "", fmt.Errorf("quote failed: %w", err) + } + + result.QuoteLatencyMs = time.Since(quoteStart).Milliseconds() + svc, _ := strconv.ParseFloat(quote.Fees.RelayerService.AmountUsd, 64) + gas, _ := strconv.ParseFloat(quote.Fees.RelayerGas.AmountUsd, 64) + result.QuoteFeeUSD = svc + gas + if outUsd, err := strconv.ParseFloat(quote.Details.CurrencyOut.AmountUsd, 64); err == nil { + result.OutputUSD = outUsd + } + + // Find approval step (if any) and bridge step (main step) + // Relay sometimes returns 2 steps: [0]=approve (EVM), [1]=bridge/deposit + approvalStepIdx := -1 + bridgeStepIdx := -1 + for i, s := range quote.Steps { + if strings.EqualFold(s.ID, "approve") || strings.EqualFold(s.ID, "approval") { + approvalStepIdx = i + } else { + bridgeStepIdx = i + } + } + // Fallback: if no explicit approve step, use step[0] as bridge + if bridgeStepIdx == -1 && len(quote.Steps) > 0 { + bridgeStepIdx = 0 + } + if bridgeStepIdx == -1 { + log.Printf(" [relay] ❌ No bridge step in quote") + return result, "", fmt.Errorf("no bridge step in quote") + } + + bridgeStep := quote.Steps[bridgeStepIdx] + hasSolanaInstructions := len(bridgeStep.Items) > 0 && len(bridgeStep.Items[0].Data.Instructions) > 0 + hasEVMTx := len(bridgeStep.Items) > 0 && bridgeStep.Items[0].Data.To != "" + + log.Printf(" [relay] ✅ Quote received: svcFee=$%.4f, gasFee=$%.4f, steps=%d (approve=%d, bridge=%d), hasSolanaInstructions=%v, hasEVMTx=%v, requestID=%s", + svc, gas, len(quote.Steps), approvalStepIdx, bridgeStepIdx, hasSolanaInstructions, hasEVMTx, bridgeStep.RequestId) + + // Execute approval first if present (EVM only) + if approvalStepIdx >= 0 { + approveStep := quote.Steps[approvalStepIdx] + if len(approveStep.Items) > 0 && approveStep.Items[0].Data.To != "" { + item := approveStep.Items[0] + log.Printf(" [relay] 📝 Sending approval TX to=%s", item.Data.To) + approvalHash, err := e.txExecutor.ExecuteEVMTransaction(route.FromChain, item.Data.To, item.Data.Data, item.Data.Value) + if err != nil { + return result, "", fmt.Errorf("approval failed: %w", err) + } + log.Printf(" [relay] ✅ Approval TX sent: %s", approvalHash) + log.Printf(" [relay] ⏳ Waiting for approval confirmation...") + time.Sleep(5 * time.Second) + success, err := e.txExecutor.CheckEVMTxStatus(route.FromChain, approvalHash) + if err != nil || !success { + return result, approvalHash, fmt.Errorf("approval not confirmed") + } + log.Printf(" [relay] ✅ Approval confirmed") + } + } + + // Execute bridge transaction + execStart := time.Now() + var txHash string + + if hasSolanaInstructions { + item := bridgeStep.Items[0] + log.Printf(" [relay] 📤 Building Solana TX from %d instructions", len(item.Data.Instructions)) + txHash, err = e.txExecutor.ExecuteSolanaFromInstructions(item.Data.Instructions, item.Data.AddressLookupTableAddresses) + } else if hasEVMTx { + item := bridgeStep.Items[0] + log.Printf(" [relay] 📤 Broadcasting EVM TX to=%s, value=%s", item.Data.To, item.Data.Value) + txHash, err = e.txExecutor.ExecuteEVMTransaction(route.FromChain, item.Data.To, item.Data.Data, item.Data.Value) + } else { + log.Printf(" [relay] ❌ No TX data in bridge step: steps=%d", len(quote.Steps)) + return result, "", fmt.Errorf("no transaction data in quote") + } + + if err != nil { + log.Printf(" [relay] ❌ Broadcast error: %v", err) + return result, "", fmt.Errorf("broadcast failed: %w", err) + } + + log.Printf(" [relay] ✅ TX broadcast: %s", txHash) + + // Poll status using request ID from the bridge step + requestID := bridgeStep.RequestId + if requestID == "" { + log.Printf(" [relay] ❌ No requestID in bridge step") + return result, txHash, fmt.Errorf("no requestID in quote response") + } + + log.Printf(" [relay] ⏳ Polling status (requestID: %s, timeout: 5min)...", requestID) + + status, err := e.txExecutor.PollRelayStatus(requestID, 5*time.Minute) + execEnd := time.Now() + + if err != nil { + log.Printf(" [relay] ❌ Status poll error: %v", err) + return result, txHash, fmt.Errorf("status poll failed: %w", err) + } + + result.ExecutionLatencyMs = execEnd.Sub(execStart).Milliseconds() + result.E2ELatencyMs = execEnd.Sub(quoteStart).Milliseconds() + + log.Printf(" [relay] 🏁 Final status: %s (exec: %dms, e2e: %dms)", + status.Status, result.ExecutionLatencyMs, result.E2ELatencyMs) + + if status.Status == "filled" || status.Status == "settled" { + result.Success = true + result.ActualFeeUSD = result.QuoteFeeUSD + } else if status.Status == "refunded" { + result.Reverted = true + log.Printf(" [relay] ⚠️ Transaction was refunded!") + } + + return result, txHash, nil +} + +// executeLiFi handles Li.Fi bridge execution +func (e *Executor) executeLiFi(route TestRoute, rawUnits string, quoteStart time.Time) (*ExecutionResult, string, error) { + result := &ExecutionResult{Bridge: "lifi", Route: route} + + // Determine sender address based on source chain + senderAddress := e.walletManager.EVMAddress + receiverAddress := e.walletManager.EVMAddress + if route.FromChain == "Solana" { + senderAddress = e.walletManager.SolanaAddress + } + if route.ToChain == "Solana" { + receiverAddress = e.walletManager.SolanaAddress + } + + log.Printf(" [lifi] Getting quote: %s → %s, rawUnits: %s, sender: %s", route.FromChain, route.ToChain, rawUnits, senderAddress[:8]+"...") + + // Get quote with TX + quote, _, err := e.lifi.GetQuote(route, rawUnits, senderAddress, receiverAddress) + if err != nil { + log.Printf(" [lifi] ❌ Quote error: %v", err) + return result, "", fmt.Errorf("quote failed: %w", err) + } + + result.QuoteLatencyMs = time.Since(quoteStart).Milliseconds() + for _, f := range quote.Estimate.FeeCosts { + v, _ := strconv.ParseFloat(f.AmountUSD, 64) + result.QuoteFeeUSD += v + } + if outUsd, err := strconv.ParseFloat(quote.Estimate.ToAmountUSD, 64); err == nil { + result.OutputUSD = outUsd + } + + // For Solana source: TX is base64 in transactionRequest.data, To will be empty + // For EVM source: TX is in transactionRequest with To, Data, Value + isSolanaTx := route.FromChain == "Solana" && quote.TransactionRequest.To == "" && quote.TransactionRequest.Data != "" + isEVMTx := quote.TransactionRequest.To != "" + needsApproval := quote.Estimate.ApprovalAddress != "" && isEVMTx + + log.Printf(" [lifi] ✅ Quote received: fee=$%.4f, tool=%s, isSolanaTx=%v, isEVMTx=%v, dataLen=%d, needsApproval=%v", + result.QuoteFeeUSD, quote.Tool, + isSolanaTx, isEVMTx, len(quote.TransactionRequest.Data), needsApproval, + ) + + // Step 1: Send ERC-20 approval if required + if needsApproval { + log.Printf(" [lifi] 📝 Sending ERC-20 approval to spender=%s for token=%s", + quote.Estimate.ApprovalAddress, quote.Action.FromToken.Address) + approvalHash, err := e.txExecutor.ApproveERC20( + route.FromChain, + quote.Action.FromToken.Address, + quote.Estimate.ApprovalAddress, + quote.Action.FromAmount, + ) + if err != nil { + log.Printf(" [lifi] ❌ Approval TX failed: %v", err) + return result, "", fmt.Errorf("approval failed: %w", err) + } + log.Printf(" [lifi] ✅ Approval TX sent: %s", approvalHash) + + // Wait for approval to confirm + log.Printf(" [lifi] ⏳ Waiting for approval confirmation...") + time.Sleep(3 * time.Second) + + success, err := e.txExecutor.CheckEVMTxStatus(route.FromChain, approvalHash) + if err != nil || !success { + log.Printf(" [lifi] ❌ Approval TX failed to confirm") + return result, approvalHash, fmt.Errorf("approval not confirmed") + } + log.Printf(" [lifi] ✅ Approval confirmed") + } + + // Step 2: Execute bridge transaction + execStart := time.Now() + var txHash string + + if isSolanaTx { + // Solana transaction - data field contains base64 serialized TX + log.Printf(" [lifi] 📤 Broadcasting Solana TX (len=%d)", len(quote.TransactionRequest.Data)) + txHash, err = e.txExecutor.ExecuteSolanaTransaction(quote.TransactionRequest.Data) + } else if isEVMTx { + // EVM transaction + log.Printf(" [lifi] 📤 Broadcasting EVM TX to=%s, value=%s, chainId=%d", + quote.TransactionRequest.To, quote.TransactionRequest.Value, quote.TransactionRequest.ChainId) + txHash, err = e.txExecutor.ExecuteEVMTransaction( + route.FromChain, + quote.TransactionRequest.To, + quote.TransactionRequest.Data, + quote.TransactionRequest.Value, + ) + } else { + log.Printf(" [lifi] ❌ No TX data in quote response (To=%s, DataLen=%d)", quote.TransactionRequest.To, len(quote.TransactionRequest.Data)) + return result, "", fmt.Errorf("no transaction data in quote") + } + + if err != nil { + log.Printf(" [lifi] ❌ Broadcast error: %v", err) + return result, "", fmt.Errorf("broadcast failed: %w", err) + } + + log.Printf(" [lifi] ✅ TX broadcast: %s", txHash) + + // Poll status + fromChain := lifiChainID(route.FromChain) + toChain := lifiChainID(route.ToChain) + log.Printf(" [lifi] ⏳ Polling status (fromChain: %s, toChain: %s, timeout: 5min)...", fromChain, toChain) + + status, err := e.txExecutor.PollLiFiStatus(txHash, fromChain, toChain, 5*time.Minute) + execEnd := time.Now() + + if err != nil { + log.Printf(" [lifi] ❌ Status poll error: %v", err) + return result, txHash, fmt.Errorf("status poll failed: %w", err) + } + + result.ExecutionLatencyMs = execEnd.Sub(execStart).Milliseconds() + result.E2ELatencyMs = execEnd.Sub(quoteStart).Milliseconds() + + log.Printf(" [lifi] 🏁 Final status: %s (exec: %dms, e2e: %dms)", + status.Status, result.ExecutionLatencyMs, result.E2ELatencyMs) + + if status.Status == "filled" || status.Status == "settled" { + result.Success = true + result.ActualFeeUSD = result.QuoteFeeUSD + } else if status.Status == "refunded" || status.Status == "failed" { + result.Reverted = true + log.Printf(" [lifi] ⚠️ Transaction was refunded/failed!") + } + + return result, txHash, nil +} + +// recordExecutionMetrics records the execution results to Prometheus +func (e *Executor) recordExecutionMetrics(result *ExecutionResult) { + amountStr := strconv.FormatFloat(result.AmountUSD, 'f', 0, 64) + labels := []string{ + result.Bridge, + result.Route.FromChain, + result.Route.ToChain, + result.Route.FromToken, + result.Route.ToToken, + amountStr, + e.region, + } + + // Record latencies + bridgeQuoteLatency.WithLabelValues(labels...).Observe(float64(result.QuoteLatencyMs)) + bridgeExecutionLatency.WithLabelValues(labels...).Observe(float64(result.ExecutionLatencyMs)) + bridgeE2ELatency.WithLabelValues(labels...).Observe(float64(result.E2ELatencyMs)) + + // Record success/revert + consecutive-failure streak (used for paging alerts). + if result.Success { + bridgeSuccess.WithLabelValues(labels...).Inc() + bridgeConsecutiveFailures.WithLabelValues(result.Bridge, e.region).Set(0) + } + if result.Reverted { + bridgeReverts.WithLabelValues(labels...).Inc() + bridgeConsecutiveFailures.WithLabelValues(result.Bridge, e.region).Inc() + } + if result.Error != nil { + bridgeErrors.WithLabelValues(append(labels, "execution_failed")...).Inc() + if !result.Reverted { + bridgeConsecutiveFailures.WithLabelValues(result.Bridge, e.region).Inc() + } + } + + // Record fees + bridgeFeesUSD.WithLabelValues(labels...).Set(result.ActualFeeUSD) + if result.AmountUSD > 0 { + bridgeFeesPercent.WithLabelValues(labels...).Set((result.ActualFeeUSD / result.AmountUSD) * 100) + } +} + +// getSourceTokenName returns the source token name for balance checking (legacy - fallback) +func getSourceTokenName(route TestRoute) string { + switch route.Name { + case "TRUMP_SOL_BRETT_BASE": + return "TRUMP" + case "USDT_ARB_USDC_SOL": + return "USDT" + default: + return "USDC" + } +} + +// getAvailableBalance looks up balance first by contract address (robust against symbol mismatch +// like USDT vs USDT0), then falls back to symbol name. +func getAvailableBalance(balances map[string]map[string]float64, route TestRoute) float64 { + chainBalances := balances[route.FromChain] + if chainBalances == nil { + return 0 + } + // Try by contract address first (case-insensitive) + if bal, ok := chainBalances[strings.ToLower(route.FromToken)]; ok { + return bal + } + // Fallback to symbol-based lookup + return chainBalances[getSourceTokenName(route)] +} + +// testBridgeDryRun simulates a single bridge test +func (e *Executor) testBridgeDryRun(bridge string, route TestRoute, amountUSD float64) { + log.Printf(" [%s] Simulating $%.0f %s...", bridge, amountUSD, route.Name) + + // Calculate raw units - for USDC/USDT amount equals USD, for TRUMP convert + amount := amountUSD + if route.Name == "TRUMP_SOL_BRETT_BASE" { + amount = amountUSD / TokenPriceUSD("TRUMP", 2.55) // live TRUMP price (5min cache) + } + rawUnits := toRawUnits(amount) + + // Determine sender address based on source chain + senderAddress := e.walletManager.EVMAddress + receiverAddress := e.walletManager.EVMAddress + if route.FromChain == "Solana" { + senderAddress = e.walletManager.SolanaAddress + } + if route.ToChain == "Solana" { + receiverAddress = e.walletManager.SolanaAddress + } + + // Get quote + quoteStart := time.Now() + var quoteFee float64 + var quoteErr error + var estimatedTimeMs int64 + + switch bridge { + case "mobula": + quote, _, err := e.mobula.GetQuote( + route.FromChainAPI, route.FromToken, + route.ToChainAPI, route.ToToken, + senderAddress, receiverAddress, + amount, + ) + if err != nil { + quoteErr = err + } else { + quoteFee, _ = strconv.ParseFloat(quote.Data.Fees.TotalFeeUsd, 64) + estimatedTimeMs = quote.Data.EstimatedTimeMs + } + + case "relay": + quote, _, err := e.relay.GetQuote(route, rawUnits, senderAddress, receiverAddress) + if err != nil { + quoteErr = err + } else { + // Parse relay fees + svc, _ := strconv.ParseFloat(quote.Fees.RelayerService.AmountUsd, 64) + gas, _ := strconv.ParseFloat(quote.Fees.RelayerGas.AmountUsd, 64) + quoteFee = svc + gas + estimatedTimeMs = int64(quote.Details.TimeEstimate * 1000) + } + + case "lifi": + quote, _, err := e.lifi.GetQuote(route, rawUnits, senderAddress, receiverAddress) + if err != nil { + quoteErr = err + } else { + for _, f := range quote.Estimate.FeeCosts { + v, _ := strconv.ParseFloat(f.AmountUSD, 64) + quoteFee += v + } + estimatedTimeMs = int64(quote.Estimate.ExecutionDuration * 1000) + } + + case "debridge": + quote, _, err := e.debridge.GetQuote(route, rawUnits) + if err != nil { + quoteErr = err + } else { + quoteFee = debridgeFixFeeUSD(route.FromChain) + quote.ProtocolFeeApproximateUsdValue + estimatedTimeMs = quote.Order.ApproximateFulfillmentDelay * 1000 + } + } + + quoteLatency := time.Since(quoteStart) + + if quoteErr != nil { + log.Printf(" ❌ Quote failed: %v", quoteErr) + return + } + + log.Printf(" ✅ Quote: %dms | Fee: $%.4f | Est: %dms", quoteLatency.Milliseconds(), quoteFee, estimatedTimeMs) + + // In dry-run mode, we simulate the execution + log.Printf(" 🔸 [DRY-RUN] Would broadcast TX here") + log.Printf(" 🔸 [DRY-RUN] Would poll status until completion") + log.Printf(" 🔸 [DRY-RUN] Would record execution latency") + + // Simulate expected cost + expectedCost := quoteFee + 0.10 // Add estimated gas + log.Printf(" 💰 Estimated cost: $%.4f", expectedCost) + + // Update daily spending tracker (even in dry-run for estimation) + e.config.DailySpentUSD += expectedCost + log.Printf(" 📈 Daily spend estimate: $%.2f / $%.2f max", e.config.DailySpentUSD, e.config.MaxDailySpendUSD) +} + +// ValidateSetup checks that everything is configured correctly +func (e *Executor) ValidateSetup() error { + log.Println("🔍 Validating execution setup...") + + // Check wallet configuration + if e.walletManager == nil { + return fmt.Errorf("wallet manager not configured") + } + + if e.walletManager.EVMAddress == "" { + return fmt.Errorf("EVM wallet address not configured") + } + log.Printf(" ✅ EVM Address: %s", e.walletManager.EVMAddress) + + if e.walletManager.SolanaAddress == "" { + return fmt.Errorf("Solana wallet address not configured") + } + log.Printf(" ✅ Solana Address: %s", e.walletManager.SolanaAddress) + + // Check balance checker + if e.balanceCheck == nil { + return fmt.Errorf("balance checker not configured") + } + + // Verify we can fetch balances + balances, err := e.balanceCheck.GetAllBalances() + if err != nil { + return fmt.Errorf("cannot fetch balances: %w", err) + } + + // Log all balances + totalUSD := 0.0 + for chain, tokens := range balances { + for token, bal := range tokens { + log.Printf(" 💰 %s/%s: $%.2f", chain, token, bal) + totalUSD += bal + } + } + log.Printf(" 📊 Total portfolio: $%.2f", totalUSD) + + // Check execution config + log.Printf(" ⚙️ Mode: %s", e.config.Mode) + log.Printf(" ⚙️ $5 frequency: %v", e.config.Freq5USD) + log.Printf(" ⚙️ $50 frequency: %v", e.config.Freq50USD) + log.Printf(" ⚙️ $300 frequency: %v", e.config.Freq300USD) + log.Printf(" ⚙️ Debridge execution: %v", e.config.EnableDebridge) + log.Printf(" ⚙️ Max daily spend: $%.2f", e.config.MaxDailySpendUSD) + + log.Println("✅ Setup validation complete") + return nil +} + +// EstimateMonthlyCost calculates expected monthly costs +func (e *Executor) EstimateMonthlyCost() { + log.Println("💰 Estimating monthly costs...") + + // Costs per execution (from analysis). $300 is the new "large ticket" tier + // (down from $500 — capital constraint on current wallet, see README). + costPer5 := 1.55 // M/R/L combined for 3 routes + costPer50 := 2.85 + costPer300 := 9.02 // ~$3.01/cycle × 3 bridges = 9 TX + + if e.config.EnableDebridge { + costPer5 += 8.30 + costPer50 += 8.95 + costPer300 += 11.00 + } + + // Calculate monthly executions based on frequency + daysInMonth := 30.0 + + exec5PerMonth := (24 * daysInMonth) / e.config.Freq5USD.Hours() + exec50PerMonth := (24 * daysInMonth) / e.config.Freq50USD.Hours() + exec300PerMonth := (24 * daysInMonth) / e.config.Freq300USD.Hours() + + cost5 := exec5PerMonth * costPer5 + cost50 := exec50PerMonth * costPer50 + cost300 := exec300PerMonth * costPer300 + + totalMonthly := cost5 + cost50 + cost300 + + log.Printf(" $5 tests: %.0f/month × $%.2f = $%.2f", exec5PerMonth, costPer5, cost5) + log.Printf(" $50 tests: %.0f/month × $%.2f = $%.2f", exec50PerMonth, costPer50, cost50) + log.Printf(" $300 tests: %.0f/month × $%.2f = $%.2f", exec300PerMonth, costPer300, cost300) + log.Printf(" ─────────────────────────────────") + log.Printf(" TOTAL: $%.2f/month", totalMonthly) + + // Estimate duration with current capital + balances, _ := e.balanceCheck.GetAllBalances() + totalCapital := 0.0 + for _, tokens := range balances { + for _, bal := range tokens { + totalCapital += bal + } + } + + if totalMonthly > 0 { + months := totalCapital / totalMonthly + log.Printf(" ⏱️ Estimated duration: %.1f months with $%.0f capital", months, totalCapital) + } +} + +// PrintExecutionPlan shows what will be executed +func (e *Executor) PrintExecutionPlan() { + plan := map[string]interface{}{ + "mode": e.config.Mode, + "freq_5_usd": e.config.Freq5USD.String(), + "freq_50_usd": e.config.Freq50USD.String(), + "freq_300_usd": e.config.Freq300USD.String(), + "enable_debridge": e.config.EnableDebridge, + "max_daily_spend": e.config.MaxDailySpendUSD, + "evm_address": e.walletManager.EVMAddress, + "solana_address": e.walletManager.SolanaAddress, + } + + planJSON, _ := json.MarshalIndent(plan, "", " ") + log.Printf("📋 Execution Plan:\n%s", string(planJSON)) +} diff --git a/harnesses/bridge-monitor/cmd/monitor/lifi_bridge.go b/harnesses/bridge-monitor/cmd/monitor/lifi_bridge.go new file mode 100644 index 00000000..82c928e1 --- /dev/null +++ b/harnesses/bridge-monitor/cmd/monitor/lifi_bridge.go @@ -0,0 +1,193 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strconv" + "strings" + "time" +) + +type LiFiBridge struct { + client *http.Client + apiKey string +} + +func NewLiFiBridge(apiKey string) *LiFiBridge { + return &LiFiBridge{ + client: &http.Client{Timeout: 45 * time.Second}, + apiKey: apiKey, + } +} + +// Chain ID mapping for Li.Fi +func lifiChainID(chain string) string { + switch strings.ToLower(chain) { + case "solana": + return "1151111081099710" + case "base": + return "8453" + case "arbitrum": + return "42161" + case "hypercore", "hyperliquid": + return "1337" + } + return "" +} + +// lifiDestToken translates the abstract ToToken from a TestRoute into the +// destination address LiFi expects. For HyperCore, LiFi accepts the Arb USDC +// address as a marker for "USDC" generally — we pass that. +func lifiDestToken(route TestRoute) string { + if route.ToChain == "HyperCore" && strings.EqualFold(route.ToToken, "USDC") { + return "0xaf88d065e77c8cc2239327c5edb3a432268e5831" + } + return route.ToToken +} + +type LiFiFeeCost struct { + Name string `json:"name"` + AmountUSD string `json:"amountUSD"` +} + +type LiFiQuoteResponse struct { + Estimate struct { + FromAmountUSD string `json:"fromAmountUSD"` + ToAmountUSD string `json:"toAmountUSD"` + ExecutionDuration float64 `json:"executionDuration"` + FeeCosts []LiFiFeeCost `json:"feeCosts"` + ApprovalAddress string `json:"approvalAddress"` // Spender for ERC-20 approval + } `json:"estimate"` + Action struct { + FromToken struct { + Address string `json:"address"` + } `json:"fromToken"` + FromAmount string `json:"fromAmount"` // Raw amount for approval + } `json:"action"` + Tool string `json:"tool"` + Message string `json:"message,omitempty"` + Code int `json:"code,omitempty"` + // Transaction data for execution + // For EVM source: uses To, Data, Value, GasLimit, ChainId + // For Solana source: the base64 TX is in Data field (To/Value/GasLimit will be empty) + TransactionRequest struct { + To string `json:"to"` + Data string `json:"data"` + Value string `json:"value"` + GasLimit string `json:"gasLimit"` + ChainId int64 `json:"chainId"` + } `json:"transactionRequest"` +} + +func (l *LiFiBridge) GetQuote(route TestRoute, rawAmount string, senderAddress, receiverAddress string) (*LiFiQuoteResponse, time.Duration, error) { + start := time.Now() + + url := fmt.Sprintf( + "https://li.quest/v1/quote?fromChain=%s&toChain=%s&fromToken=%s&toToken=%s&fromAmount=%s&fromAddress=%s&toAddress=%s&order=FASTEST", + lifiChainID(route.FromChain), lifiChainID(route.ToChain), + route.FromToken, lifiDestToken(route), + rawAmount, senderAddress, receiverAddress, + ) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, time.Since(start), fmt.Errorf("lifi request create: %w", err) + } + + // Add API key header if available + if l.apiKey != "" { + req.Header.Set("x-lifi-api-key", l.apiKey) + } + + resp, err := l.client.Do(req) + if err != nil { + return nil, time.Since(start), fmt.Errorf("lifi request: %w", err) + } + defer resp.Body.Close() + + raw, _ := io.ReadAll(resp.Body) + latency := time.Since(start) + if resp.StatusCode != http.StatusOK { + return nil, latency, fmt.Errorf("lifi %d: %s", resp.StatusCode, string(raw)) + } + + var out LiFiQuoteResponse + if err := json.Unmarshal(raw, &out); err != nil { + return nil, latency, fmt.Errorf("lifi decode: %w", err) + } + if out.Message != "" { + return nil, latency, fmt.Errorf("lifi error: %s", out.Message) + } + return &out, latency, nil +} + +func (l *LiFiBridge) TestRoute(route TestRoute, amount, amountUsd float64, rawUnits string, region, solAddress, evmAddress string) { + amountStr := strconv.FormatFloat(amountUsd, 'f', 0, 64) + labels := []string{"lifi", route.FromChain, route.ToChain, route.FromToken, route.ToToken, amountStr, region, route.ToChain} + + // Determine sender/receiver based on source/dest chains + senderAddress := evmAddress + receiverAddress := evmAddress + if route.FromChain == "Solana" { + senderAddress = solAddress + } + if route.ToChain == "Solana" { + receiverAddress = solAddress + } + + quote, quoteLatency, err := l.GetQuote(route, rawUnits, senderAddress, receiverAddress) + + if err != nil { + log.Printf("[LIFI][%s][%.0f USD] ❌ %v", route.Name, amountUsd, err) + bridgeErrors.WithLabelValues(append(labels, "quote_failed")...).Inc() + bridgeQuoteSuccess.WithLabelValues(labels...).Set(0) + return + } + + // Latency is only meaningful for quotes that returned a usable route + // (the published methodology measures exactly that). Fast failures, e.g. + // Cloudflare 403s answered in 30ms, must not enter the histogram: they + // made deBridge look 15x faster the moment its API started rejecting us. + bridgeQuoteLatency.WithLabelValues(labels...).Observe(float64(quoteLatency.Milliseconds())) + bridgeQuoteSuccess.WithLabelValues(labels...).Set(1) + + inUsd, _ := strconv.ParseFloat(quote.Estimate.FromAmountUSD, 64) + outUsd, _ := strconv.ParseFloat(quote.Estimate.ToAmountUSD, 64) + totalFeesUsd := 0.0 + for _, f := range quote.Estimate.FeeCosts { + v, _ := strconv.ParseFloat(f.AmountUSD, 64) + totalFeesUsd += v + } + + // costUsd = fees + (input - output) if positive slippage + costUsd := inUsd - outUsd + if costUsd < 0 { + // Oracle mismatch — use fees as baseline + costUsd = totalFeesUsd + } + costPct := 0.0 + if amountUsd > 0 { + costPct = (costUsd / amountUsd) * 100 + } + slippage := costUsd - totalFeesUsd + if slippage < 0 { + slippage = 0 + } + + bridgeFeesUSD.WithLabelValues(labels...).Set(totalFeesUsd) + bridgeFeesPercent.WithLabelValues(labels...).Set((totalFeesUsd / amountUsd) * 100) + bridgeCostUSD.WithLabelValues(labels...).Set(costUsd) + bridgeCostPercent.WithLabelValues(labels...).Set(costPct) + bridgeSlippageUSD.WithLabelValues(labels...).Set(slippage) + bridgeGasUSD.WithLabelValues(labels...).Set(0) // bundled in feeCosts + bridgeFixFeeUSD.WithLabelValues(labels...).Set(0) + bridgeOutputUSD.WithLabelValues(labels...).Set(outUsd) + bridgeEstimatedTimeMs.WithLabelValues(labels...).Set(quote.Estimate.ExecutionDuration * 1000) + + log.Printf("[LIFI][%s][%.0f USD] ✅ Quote: %dms | Cost: $%.4f (%.3f%%) | Tool: %s | Est: %.0fs", + route.Name, amountUsd, quoteLatency.Milliseconds(), + costUsd, costPct, quote.Tool, quote.Estimate.ExecutionDuration) +} diff --git a/harnesses/bridge-monitor/cmd/monitor/logbuffer.go b/harnesses/bridge-monitor/cmd/monitor/logbuffer.go new file mode 100644 index 00000000..de27da1b --- /dev/null +++ b/harnesses/bridge-monitor/cmd/monitor/logbuffer.go @@ -0,0 +1,113 @@ +package main + +import ( + "bytes" + "io" + "sync" + "sync/atomic" + "time" +) + +// LogBuffer is an io.Writer that mirrors writes to a chained writer (stdout) +// while keeping the last `size` log lines in a ring buffer for HTTP exposure +// via /logs. +// +// Stdout writes are async via a buffered channel + drain goroutine. If the +// channel saturates (Railway log collector wedged → OS pipe full → write(2) +// blocks), Write drops the line instead of blocking. This is critical: a +// blocking stdout write previously stalled the main scheduler goroutine for +// hours when the Railway log forwarder hiccuped. +type LogBuffer struct { + mu sync.Mutex + lines []string + head int + full bool + next io.Writer + + stdoutCh chan []byte + dropped uint64 // atomic — bytes dropped because stdoutCh was full + lastWriteNs int64 // atomic — UnixNano of last Write; used by watchdog +} + +func NewLogBuffer(next io.Writer, size int) *LogBuffer { + b := &LogBuffer{ + lines: make([]string, size), + next: next, + stdoutCh: make(chan []byte, 1024), + } + if next != nil { + go b.drainStdout() + } + return b +} + +func (b *LogBuffer) Write(p []byte) (int, error) { + atomic.StoreInt64(&b.lastWriteNs, time.Now().UnixNano()) + // Always update the in-memory ring (cheap, only mutex contention with /logs reader). + b.mu.Lock() + for _, line := range bytes.Split(bytes.TrimRight(p, "\n"), []byte("\n")) { + if len(line) == 0 { + continue + } + b.lines[b.head] = string(line) + b.head = (b.head + 1) % len(b.lines) + if b.head == 0 { + b.full = true + } + } + b.mu.Unlock() + + // Async stdout: copy because the caller can reuse p, then non-blocking send. + if b.next != nil { + cp := make([]byte, len(p)) + copy(cp, p) + select { + case b.stdoutCh <- cp: + default: + atomic.AddUint64(&b.dropped, uint64(len(p))) + } + } + return len(p), nil +} + +func (b *LogBuffer) drainStdout() { + for p := range b.stdoutCh { + _, _ = b.next.Write(p) + } +} + +// DroppedBytes returns how many stdout bytes were dropped because the async +// channel was full. Exposed for /status diagnostics. +func (b *LogBuffer) DroppedBytes() uint64 { + return atomic.LoadUint64(&b.dropped) +} + +// LastWriteAge returns how long since the last Write call. If no Write has +// happened yet (process startup), returns 0. Used by the watchdog goroutine +// to detect a fully frozen scheduler. +func (b *LogBuffer) LastWriteAge() time.Duration { + ns := atomic.LoadInt64(&b.lastWriteNs) + if ns == 0 { + return 0 + } + return time.Since(time.Unix(0, ns)) +} + +// Snapshot returns the last `n` lines (or all if n<=0). Newest line last. +func (b *LogBuffer) Snapshot(n int) []string { + b.mu.Lock() + defer b.mu.Unlock() + + var out []string + if !b.full { + out = append(out, b.lines[:b.head]...) + } else { + out = make([]string, 0, len(b.lines)) + out = append(out, b.lines[b.head:]...) + out = append(out, b.lines[:b.head]...) + } + if n > 0 && n < len(out) { + out = out[len(out)-n:] + } + return out +} diff --git a/harnesses/bridge-monitor/cmd/monitor/main.go b/harnesses/bridge-monitor/cmd/monitor/main.go new file mode 100644 index 00000000..55551933 --- /dev/null +++ b/harnesses/bridge-monitor/cmd/monitor/main.go @@ -0,0 +1,499 @@ +package main + +import ( + "fmt" + "log" + "math" + "net/http" + "os" + "runtime" + "strconv" + "strings" + "time" + + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +func main() { + // Capture all log output into a ring buffer (last 500 lines) so we can expose + // it via /logs endpoint without needing Railway dashboard access. + logRing := NewLogBuffer(os.Stdout, 500) + log.SetOutput(logRing) + + log.Println("🚀 Bridge Latency Monitor starting...") + + // Load configuration + config, err := loadEnv() + if err != nil { + log.Fatalf("Failed to load configuration: %v", err) + } + + // Log configuration + config.LogConfig() + + // Initialize bridges + var mobulaBridge *MobulaBridge + if config.MobulaAPIKey != "" { + mobulaBridge = NewMobulaBridge(config.MobulaAPIKey) + log.Println("✅ Mobula bridge initialized") + } else { + log.Println("⚠️ Mobula API key not configured, skipping") + } + + relayBridge := NewRelayBridge() + log.Println("✅ Relay bridge initialized (no key needed)") + + debridgeBridge := NewDebridgeBridge() + log.Println("✅ Debridge bridge initialized (no key needed)") + + lifiBridge := NewLiFiBridge(config.LiFiAPIKey) + if config.LiFiAPIKey != "" { + log.Println("✅ Li.Fi bridge initialized (with API key)") + } else { + log.Println("⚠️ Li.Fi bridge initialized (no API key - rate limited to 75 req/2h)") + } + + nearIntentsBridge := NewNearIntentsBridge(config.NearIntentsAPIKey) + if config.NearIntentsAPIKey != "" { + log.Println("✅ Near Intents bridge initialized (with partner JWT)") + } else { + log.Println("⚠️ Near Intents bridge initialized (anonymous mode - adds ~10 bps appFee and lower solver priority)") + } + + // Initialize wallet manager + walletManager, err := NewWalletManager(config) + if err != nil { + log.Printf("⚠️ Wallet manager initialization failed: %v", err) + } else { + walletManager.LogWalletInfo() + } + + // Use wallet addresses from config if set + solAddress := config.WalletSOLAddress + evmAddress := config.WalletEVMAddress + + // Fallback to defaults for quote-only mode + if solAddress == "" { + solAddress = "DYw8jCTfwHNRJhhmFcbXvVDTqWMEVFBX6ZKUmG5CNSKK" + } + if evmAddress == "" { + evmAddress = "0x867A784039D4842A32Ddd1277729Ad1373301458" + } + + // Initialize balance checker + var balanceChecker *BalanceChecker + if config.MobulaAPIKey != "" { + balanceChecker = NewBalanceChecker(config.MobulaAPIKey, evmAddress, solAddress) + log.Println("✅ Balance checker initialized") + + // Print initial balances + balanceChecker.PrintBalances() + } + + // Initialize Slack notifier + var slackNotifier *SlackNotifier + if config.SlackWebhookURL != "" { + slackNotifier = NewSlackNotifier( + config.SlackWebhookURL, + config.MobulaAPIKey, + evmAddress, + solAddress, + ) + log.Println("✅ Slack notifications enabled") + } + + // Initialize executor for execution mode + var executor *Executor + if config.ExecutionMode != "" { + execConfig := &ExecutionConfig{ + Mode: ExecutionMode(config.ExecutionMode), + Freq5USD: config.Freq5USD, + Freq50USD: config.Freq50USD, + Freq300USD: config.Freq300USD, + EnableDebridge: config.EnableDebridge, + MaxDailySpendUSD: config.MaxDailySpendUSD, + } + + executor = NewExecutor( + execConfig, + walletManager, + balanceChecker, + mobulaBridge, + relayBridge, + lifiBridge, + debridgeBridge, + config.MonitorRegion, + slackNotifier, + ) + + // Validate setup + if err := executor.ValidateSetup(); err != nil { + log.Printf("⚠️ Executor validation failed: %v", err) + } else { + // Print execution plan and cost estimate + executor.PrintExecutionPlan() + executor.EstimateMonthlyCost() + } + } + + // Start Prometheus metrics endpoint. Railway / most PaaS inject $PORT + // and route external traffic to whatever value they chose. If we bind + // to the wrong port the edge proxy returns 502 with x-railway-fallback. + // Honour $PORT first, then a manual METRICS_PORT override, then 9090. + metricsPort := os.Getenv("PORT") + if metricsPort == "" { + metricsPort = os.Getenv("METRICS_PORT") + } + if metricsPort == "" { + metricsPort = "9090" + } + go func() { + http.Handle("/metrics", promhttp.Handler()) + + // Health check endpoint + http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + }) + + // Status endpoint + http.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + status := fmt.Sprintf(`{ + "mode": "%s", + "evm_address": "%s", + "sol_address": "%s", + "debridge_enabled": %v, + "region": "%s" + }`, config.ExecutionMode, evmAddress, solAddress, config.EnableDebridge, config.MonitorRegion) + w.Write([]byte(status)) + }) + + // Live logs endpoint (last N lines from ring buffer). Requires LOG_TOKEN + // env var as Bearer auth so we don't expose private keys / TXs publicly. + // Usage: curl -H "Authorization: Bearer $LOG_TOKEN" https://.../logs?n=200 + http.HandleFunc("/logs", func(w http.ResponseWriter, r *http.Request) { + token := os.Getenv("LOG_TOKEN") + if token == "" { + http.Error(w, "LOG_TOKEN env var not set on monitor — endpoint disabled", http.StatusServiceUnavailable) + return + } + auth := r.Header.Get("Authorization") + if auth != "Bearer "+token { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + n := 200 + if v := r.URL.Query().Get("n"); v != "" { + if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 { + n = parsed + } + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + for _, line := range logRing.Snapshot(n) { + _, _ = w.Write([]byte(line + "\n")) + } + }) + + log.Printf("📊 Prometheus metrics server listening on :%s", metricsPort) + if err := http.ListenAndServe(":"+metricsPort, nil); err != nil { + log.Fatalf("Failed to start metrics server: %v", err) + } + }() + + // Wait for metrics server to start + time.Sleep(2 * time.Second) + + // Test routes — call GetTestRoutes()/etc. fresh inside each loop iteration so + // the TRUMP-denominated R4 amounts pick up live TRUMP price (5min cache). + // Initial values used for the first quote run + dry-run / single-test only. + allRoutes := GetTestRoutes() + triangleRoutes := GetTriangleRoutes() + + log.Println("🔄 Starting monitoring loop (5 minute interval)...") + + // Send startup notification to Slack + if slackNotifier != nil { + if err := slackNotifier.NotifyStartup(config.ExecutionMode); err != nil { + log.Printf("⚠️ Slack startup notification failed: %v", err) + } + } + + // Run quote tests immediately on startup (all routes) + runQuoteTests(mobulaBridge, relayBridge, debridgeBridge, lifiBridge, nearIntentsBridge, allRoutes, config.MonitorRegion, solAddress, evmAddress) + + // If in dry-run mode, run a single dry-run test + if config.ExecutionMode == "dry-run" && executor != nil { + log.Println("\n🧪 Running DRY-RUN execution test (triangle routes)...") + for _, route := range triangleRoutes { + executor.RunDryRun(route, 5.0) // Test with $5 + } + } + + // Single-test mode: run ONE real execution and exit. Uses the same sequential + // per-bridge orchestration as production so dry-run matches real behaviour. + if config.ExecutionMode == "single-test" && executor != nil { + testAmount := config.TestAmountUSD + if testAmount <= 0 { + testAmount = 1.0 + } + log.Printf("\n🧪 SINGLE-TEST MODE: $%.2f per bridge × 3 bridges × 3 routes = 9 TX", testAmount) + log.Println("⚠️ This will execute REAL transactions!") + + bridges := []string{"mobula", "relay", "lifi"} + for _, bridge := range bridges { + log.Printf("\n━━━ Bridge: %s ━━━", bridge) + for _, route := range triangleRoutes { + log.Printf(" → %s", route.Name) + executor.RunBridgeOnRoute(bridge, route, testAmount) + time.Sleep(2 * time.Second) + } + } + + log.Println("\n✅ Single-test complete! Exiting.") + return + } + + // Quote loop in its own goroutine, plus a per-cycle 4-minute timeout: even if + // a bridge hangs past its individual http.Client timeout (TLS / DNS edge cases + // where Go's per-request timeout doesn't fire), the cycle is abandoned so the + // next tick still runs. The cycle keeps going in a child goroutine; we just + // stop waiting on it. + go func() { + quoteTicker := time.NewTicker(5 * time.Minute) + defer quoteTicker.Stop() + for range quoteTicker.C { + done := make(chan struct{}) + go func() { + defer close(done) + runQuoteTests(mobulaBridge, relayBridge, debridgeBridge, lifiBridge, nearIntentsBridge, GetTestRoutes(), config.MonitorRegion, solAddress, evmAddress) + }() + select { + case <-done: + case <-time.After(4 * time.Minute): + log.Printf("⏱️ Quote cycle exceeded 4min — abandoning, next tick will retry") + } + } + }() + + // Watchdog: if no log line has been written for 10 minutes, dump every + // goroutine's stack trace to /logs (so we can see what was blocked AFTER + // the panic-induced restart) then panic — Railway auto-restarts the + // container on panic. Brute-force safety net for any freeze cause we + // haven't pinned down (recurring 08:16 UTC freeze even after non-blocking + // stdout fix). Also logs dropped-bytes counter every minute so we can tell + // from /logs whether stdout backpressure is the issue. + go func() { + ticker := time.NewTicker(1 * time.Minute) + defer ticker.Stop() + for range ticker.C { + age := logRing.LastWriteAge() + dropped := logRing.DroppedBytes() + if age > 10*time.Minute { + log.Printf("🚨 WATCHDOG: no logs for %s (dropped=%d bytes) — dumping goroutines and panicking to force restart", age.Truncate(time.Second), dropped) + buf := make([]byte, 1<<20) // 1MB + n := runtime.Stack(buf, true) + log.Printf("=== GOROUTINE DUMP (%d bytes) ===\n%s\n=== END DUMP ===", n, buf[:n]) + panic(fmt.Sprintf("watchdog: no log activity for %s", age)) + } + log.Printf("🐶 watchdog ok — last log %s ago, stdout dropped %d bytes total", age.Truncate(time.Second), dropped) + } + }() + + // Start wallet balance refresh loop (every 5 min) so low-balance alerts can fire + // well before the next scheduled execution. + if balanceChecker != nil { + if err := balanceChecker.ExportBalancesToMetrics(config.MonitorRegion); err != nil { + log.Printf("⚠️ Initial balance export failed: %v", err) + } + balanceTicker := time.NewTicker(5 * time.Minute) + go func() { + for range balanceTicker.C { + if err := balanceChecker.ExportBalancesToMetrics(config.MonitorRegion); err != nil { + log.Printf("⚠️ Balance export failed: %v", err) + } + } + }() + + // Daily 09:30 UTC cron: diff today vs yesterday's wallet, post delta to Slack. + StartDailyPnLCron(balanceChecker, slackNotifier) + } + + // Start execution scheduler with fixed UTC times (survives redeploys). + // Set BENCHMARK_PAUSED=true to skip scheduler init while keeping quote loop + + // watchdog active — used when bridge providers have ongoing bugs we don't want + // to keep firing real TX into (e.g. Mobula SOL-drain bug). + var scheduler *Scheduler + paused := strings.EqualFold(strings.TrimSpace(os.Getenv("BENCHMARK_PAUSED")), "true") + if paused { + log.Println("⏸️ BENCHMARK_PAUSED=true — execution scheduler disabled. Quote loop + watchdog continue.") + } else if config.ExecutionMode == "production" && executor != nil { + scheduler = NewScheduler(DefaultSchedule()) + scheduler.Start() + log.Println("🚀 Production mode: fixed-time scheduler started") + } + + // Track last meme execution day to run weekly + lastMemeDay := -1 + + // Main loop — scheduler-only (quote loop now lives in its own goroutine). + for { + select { + case <-getSchedulerChan(scheduler, "$5"): + // $5 execution loop - daily at 10:00 UTC + if executor != nil && config.ExecutionMode == "production" { + runTierIfViable(executor, balanceChecker, slackNotifier, GetTriangleRoutes(), 5.0, "daily") + + // Meme routes use independent capital (TRUMP) — always attempt, + // the per-route RunReal check catches insufficient TRUMP. + now := time.Now().UTC() + if now.Weekday() == time.Monday && now.YearDay() != lastMemeDay { + log.Println("💸 Running $5 meme execution tests (weekly)...") + for _, route := range GetMemeRoutes() { + executor.RunReal(route, 5.0) + } + lastMemeDay = now.YearDay() + } + } + + case <-getSchedulerChan(scheduler, "$50"): + if executor != nil && config.ExecutionMode == "production" { + runTierIfViable(executor, balanceChecker, slackNotifier, GetTriangleRoutes(), 50.0, "Mon+Thu") + } + + case <-getSchedulerChan(scheduler, "$300"): + if executor != nil && config.ExecutionMode == "production" { + runTierIfViable(executor, balanceChecker, slackNotifier, GetTriangleRoutes(), 300.0, "Mon weekly") + } + } + } +} + +// runTierIfViable pre-flights the full R1→R2→R3 cycle at the given tier. If the +// simulation says the cycle cannot complete, emit ONE Slack "couldn't run" message +// and skip — next scheduler tick will retry. Returns true if the tier actually ran. +func runTierIfViable(executor *Executor, bc *BalanceChecker, slack *SlackNotifier, + routes []TestRoute, tier float64, tierLabel string, +) bool { + if bc == nil { + log.Printf("⚠️ No balance checker — skipping tier $%.0f pre-flight", tier) + return false + } + + balances, err := bc.GetAllBalances() + if err != nil { + log.Printf("⚠️ Pre-flight balance fetch failed for $%.0f tier: %v", tier, err) + if slack != nil { + _ = slack.NotifyTierSkipped(tier, tierLabel, fmt.Sprintf("Balance API error: %v", err)) + } + return false + } + + sim := SimulateTriangleCycle(balances, tier) + if !sim.Viable { + log.Printf("⏭️ Tier $%.0f skipped: %s", tier, sim.Reason) + if slack != nil { + _ = slack.NotifyTierSkipped(tier, tierLabel, sim.Reason) + } + return false + } + + // Sequential per-bridge orchestration: each bridge runs a full R1→R2→R3 triangle + // before the next bridge starts. Halves the peak capital need per leg, gives a + // clean round-trip cost per provider, and unlocks larger tiers with less capital. + log.Printf("💸 Running $%.0f triangle (sequential per-bridge)...", tier) + bridges := []string{"mobula", "relay", "lifi"} + for _, bridge := range bridges { + log.Printf(" ── Bridge %s full triangle ──", bridge) + for i, route := range routes { + result := executor.RunBridgeOnRoute(bridge, route, tier) + time.Sleep(2 * time.Second) + + // Cascade-stop: if a route in this bridge's triangle fails (broadcast + // error, revert, or refund), the next route's source leg won't have + // been replenished by the previous one's settlement. Skip the rest + // of this bridge's triangle to avoid forced reverts and 5min timeouts. + if result == nil || !result.Success { + log.Printf(" ⚠️ %s route %d (%s) failed — skipping remaining %s routes (cascade prevention)", + bridge, i+1, route.Name, bridge) + break + } + } + } + return true +} + +// getSchedulerChan returns the appropriate scheduler channel or nil +func getSchedulerChan(s *Scheduler, amount string) <-chan struct{} { + if s == nil { + return nil + } + switch amount { + case "$5": + return s.Exec5Chan() + case "$50": + return s.Exec50Chan() + case "$300": + return s.Exec300Chan() + } + return nil +} + +// All our source tokens (USDC, USDT, TRUMP) use 6 decimals. +const tokenDecimals = 6 + +func toRawUnits(amount float64) string { + raw := amount * math.Pow10(tokenDecimals) + return strconv.FormatInt(int64(math.Round(raw)), 10) +} + +// runQuoteTests runs quote-only tests (FREE, no execution) +func runQuoteTests( + mobulaBridge *MobulaBridge, + relayBridge *RelayBridge, + debridgeBridge *DebridgeBridge, + lifiBridge *LiFiBridge, + nearIntentsBridge *NearIntentsBridge, + routes []TestRoute, region, solAddress, evmAddress string, +) { + timestamp := time.Now().Format("2006-01-02 15:04:05") + log.Printf("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + log.Printf("⏰ Quote Test Run: %s", timestamp) + log.Printf("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") + + for _, route := range routes { + for i, amount := range route.Amounts { + amountUsd := amount + if i < len(route.UsdAmounts) { + amountUsd = route.UsdAmounts[i] + } + rawUnits := toRawUnits(amount) + + if mobulaBridge != nil { + mobulaBridge.TestRoute(route, amount, amountUsd, region, solAddress, evmAddress) + time.Sleep(500 * time.Millisecond) + } + relayBridge.TestRoute(route, amount, amountUsd, rawUnits, region, solAddress, evmAddress) + time.Sleep(500 * time.Millisecond) + + // Debridge doesn't support HyperCore (chain not in their whitelist) — + // skip cleanly to avoid spamming bridge_errors_total{error_type="quote_failed"}. + if route.ToChain != "HyperCore" { + debridgeBridge.TestRoute(route, amount, amountUsd, rawUnits, region) + time.Sleep(500 * time.Millisecond) + } + + lifiBridge.TestRoute(route, amount, amountUsd, rawUnits, region, solAddress, evmAddress) + time.Sleep(500 * time.Millisecond) + + // Near Intents covers Sol/Base/Arb USDC bi-directionally and HyperCore + // as destination only. Sources outside that set return early inside + // TestRoute without emitting metrics, so the bench-fee bucket stays + // honest (no fake unsupported-route failures). + nearIntentsBridge.TestRoute(route, amount, amountUsd, rawUnits, region, solAddress, evmAddress) + time.Sleep(500 * time.Millisecond) + } + } + + log.Println("") +} diff --git a/harnesses/bridge-monitor/cmd/monitor/metrics.go b/harnesses/bridge-monitor/cmd/monitor/metrics.go new file mode 100644 index 00000000..0ac368ae --- /dev/null +++ b/harnesses/bridge-monitor/cmd/monitor/metrics.go @@ -0,0 +1,131 @@ +package main + +import ( + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +var ( + // Quote latency (time to get quote response) + bridgeQuoteLatency = promauto.NewHistogramVec(prometheus.HistogramOpts{ + Name: "bridge_quote_latency_ms", + Help: "Latency to get bridge quote in milliseconds", + Buckets: []float64{50, 100, 200, 500, 1000, 2000, 5000, 10000}, + }, []string{"bridge", "from_chain", "to_chain", "from_token", "to_token", "amount_usd", "region", "chain"}) + + // Execution latency (broadcast to funds received) + bridgeExecutionLatency = promauto.NewHistogramVec(prometheus.HistogramOpts{ + Name: "bridge_execution_latency_ms", + Help: "Latency from broadcast to funds received in milliseconds", + Buckets: []float64{1000, 5000, 10000, 30000, 60000, 120000, 300000, 600000}, + }, []string{"bridge", "from_chain", "to_chain", "from_token", "to_token", "amount_usd", "region", "chain"}) + + // End-to-end latency (quote to funds received) + bridgeE2ELatency = promauto.NewHistogramVec(prometheus.HistogramOpts{ + Name: "bridge_e2e_latency_ms", + Help: "End-to-end latency from quote to funds received in milliseconds", + Buckets: []float64{1000, 5000, 10000, 30000, 60000, 120000, 300000, 600000}, + }, []string{"bridge", "from_chain", "to_chain", "from_token", "to_token", "amount_usd", "region", "chain"}) + + // Fees in USD + bridgeFeesUSD = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "bridge_fees_usd", + Help: "Bridge fees in USD", + }, []string{"bridge", "from_chain", "to_chain", "from_token", "to_token", "amount_usd", "region", "chain"}) + + // Fees in percentage + bridgeFeesPercent = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "bridge_fees_percent", + Help: "Bridge fees as percentage of amount", + }, []string{"bridge", "from_chain", "to_chain", "from_token", "to_token", "amount_usd", "region", "chain"}) + + // Success counter + bridgeSuccess = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "bridge_success_total", + Help: "Total number of successful bridge transactions", + }, []string{"bridge", "from_chain", "to_chain", "from_token", "to_token", "amount_usd", "region", "chain"}) + + // Revert counter + bridgeReverts = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "bridge_reverts_total", + Help: "Total number of reverted/refunded bridge transactions", + }, []string{"bridge", "from_chain", "to_chain", "from_token", "to_token", "amount_usd", "region", "chain"}) + + // Error counter + bridgeErrors = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "bridge_errors_total", + Help: "Total number of bridge errors", + }, []string{"bridge", "from_chain", "to_chain", "from_token", "to_token", "amount_usd", "region", "chain", "error_type"}) + + // NOTE: bridge_revert_rate was previously declared as a gauge but never populated. + // The dashboard and HighBridgeRevertRate alert now compute the rate directly from + // bridge_reverts_total and bridge_success_total via promql, so the dead gauge was + // removed. Keep this comment as a reminder if someone wants to re-add a snapshot. + + // Total cost in USD (fees + slippage + gas) - extracted from quote + bridgeCostUSD = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "bridge_cost_usd", + Help: "Total cost in USD (bridge fees + slippage + gas) extracted from quote", + }, []string{"bridge", "from_chain", "to_chain", "from_token", "to_token", "amount_usd", "region", "chain"}) + + // Total cost as percentage of amount + bridgeCostPercent = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "bridge_cost_percent", + Help: "Total cost as percentage of amount", + }, []string{"bridge", "from_chain", "to_chain", "from_token", "to_token", "amount_usd", "region", "chain"}) + + // Slippage in USD (output - input) + bridgeSlippageUSD = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "bridge_slippage_usd", + Help: "Slippage in USD (difference between input and output value)", + }, []string{"bridge", "from_chain", "to_chain", "from_token", "to_token", "amount_usd", "region", "chain"}) + + // Gas fee in USD + bridgeGasUSD = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "bridge_gas_usd", + Help: "Gas fees in USD", + }, []string{"bridge", "from_chain", "to_chain", "from_token", "to_token", "amount_usd", "region", "chain"}) + + // Fix fee (Debridge-specific, in USD) + bridgeFixFeeUSD = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "bridge_fix_fee_usd", + Help: "Fixed fee in USD (Debridge native-token fees)", + }, []string{"bridge", "from_chain", "to_chain", "from_token", "to_token", "amount_usd", "region", "chain"}) + + // Estimated execution time (from quote, bridge's promise) + bridgeEstimatedTimeMs = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "bridge_estimated_time_ms", + Help: "Estimated execution time in ms as promised by the bridge quote", + }, []string{"bridge", "from_chain", "to_chain", "from_token", "to_token", "amount_usd", "region", "chain"}) + + // Quote success (1 = quote returned, 0 = error/unsupported) + bridgeQuoteSuccess = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "bridge_quote_success", + Help: "1 if the quote returned successfully, 0 if error/unsupported", + }, []string{"bridge", "from_chain", "to_chain", "from_token", "to_token", "amount_usd", "region", "chain"}) + + // Output amount in USD + bridgeOutputUSD = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "bridge_output_usd", + Help: "Output amount in USD after bridge", + }, []string{"bridge", "from_chain", "to_chain", "from_token", "to_token", "amount_usd", "region", "chain"}) + + // Wallet balance in USD, per chain / token. Drives low-balance alerts. + walletBalanceUSD = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "wallet_balance_usd", + Help: "Wallet balance in USD, labelled by chain and token (triangle + gas)", + }, []string{"chain", "token", "region"}) + + // Updated to now() each time the wallet balances are successfully refreshed. + // Lets us alert if the refresh loop stops. + walletBalanceLastUpdate = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "wallet_balance_last_update_timestamp_seconds", + Help: "Unix timestamp of the last successful wallet balance refresh", + }) + + // Consecutive execution failures per bridge. Resets on any success. + bridgeConsecutiveFailures = promauto.NewGaugeVec(prometheus.GaugeOpts{ + Name: "bridge_consecutive_failures", + Help: "Number of consecutive execution failures for a bridge (resets on success)", + }, []string{"bridge", "region"}) +) diff --git a/harnesses/bridge-monitor/cmd/monitor/mobula_bridge.go b/harnesses/bridge-monitor/cmd/monitor/mobula_bridge.go new file mode 100644 index 00000000..73e2a41d --- /dev/null +++ b/harnesses/bridge-monitor/cmd/monitor/mobula_bridge.go @@ -0,0 +1,402 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strconv" + "time" +) + +type MobulaBridge struct { + apiKey string + client *http.Client +} + +type MobulaQuoteResponse struct { + Data struct { + EstimatedAmountOut string `json:"estimatedAmountOut"` + EstimatedAmountOutUsd string `json:"estimatedAmountOutUsd"` + EstimatedTimeMs int64 `json:"estimatedTimeMs"` + MaxTradeUsd int64 `json:"maxTradeUsd"` + Fees struct { + BridgeFeeBps int `json:"bridgeFeeBps"` + GasFeeUsd string `json:"gasFeeUsd"` + TotalFeeUsd string `json:"totalFeeUsd"` + } `json:"fees"` + // Deposit transaction data (new API format) + Deposit struct { + Solana struct { + Type string `json:"type"` + SerializedTx string `json:"serializedTx"` // Base64 Solana TX + } `json:"solana"` // Solana TX for Solana sources + EVM struct { + To string `json:"to"` + Data string `json:"data"` + Value string `json:"value"` + } `json:"evm"` // EVM TX for EVM sources + } `json:"deposit"` + // Legacy fields (kept for compatibility) + SerializedTx string `json:"serializedTx"` + // Steps array with approve + bridgeToken + Steps []struct { + Type string `json:"type"` // "approve" or "bridgeToken" + Description string `json:"description"` + Tx struct { + To string `json:"to"` + Data string `json:"data"` + Value string `json:"value"` + } `json:"tx"` + } `json:"steps"` + } `json:"data"` +} + +type MobulaStatusResponse struct { + Data struct { + Status string `json:"status"` // "pending", "filled", "refunded", "failed" + LatencyMs int64 `json:"latencyMs"` + FromTxHash string `json:"fromTxHash"` + ToTxHash string `json:"toTxHash"` + } `json:"data"` +} + +type MobulaRoutesResponse struct { + Data struct { + Routes []struct { + OriginChainId string `json:"originChainId"` + DestinationChainId string `json:"destinationChainId"` + EstimatedTimeMs int64 `json:"estimatedTimeMs"` + MaxTradeUsd int64 `json:"maxTradeUsd"` + FeeBps int `json:"feeBps"` + SupportedTokens string `json:"supportedTokens"` + } `json:"routes"` + } `json:"data"` +} + +func NewMobulaBridge(apiKey string) *MobulaBridge { + return &MobulaBridge{ + apiKey: apiKey, + client: &http.Client{Timeout: 45 * time.Second}, + } +} + +// APIKey returns the API key for use in status polling +func (m *MobulaBridge) APIKey() string { + return m.apiKey +} + +func (m *MobulaBridge) GetQuote(originChain, originToken, destChain, destToken, senderAddress, walletAddress string, amount float64) (*MobulaQuoteResponse, time.Duration, error) { + start := time.Now() + + url := fmt.Sprintf( + "https://api.mobula.io/api/2/bridge/quote?originChainId=%s&originToken=%s&destinationChainId=%s&destinationToken=%s&amount=%s&walletAddress=%s&apiKey=%s", + originChain, originToken, destChain, destToken, + strconv.FormatFloat(amount, 'f', -1, 64), + walletAddress, m.apiKey, + ) + if senderAddress != "" { + url += "&senderAddress=" + senderAddress + } + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, 0, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := m.client.Do(req) + if err != nil { + return nil, 0, fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + latency := time.Since(start) + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, latency, fmt.Errorf("API error %d: %s", resp.StatusCode, string(body)) + } + + var quote MobulaQuoteResponse + if err := json.Unmarshal(body, "e); err != nil { + return nil, latency, fmt.Errorf("failed to decode response: %w", err) + } + + // API returns 200 with {"error": "..."} on business errors + var errResp struct { + Error string `json:"error"` + } + if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error != "" { + return nil, latency, fmt.Errorf("API business error: %s", errResp.Error) + } + + return "e, latency, nil +} + +func (m *MobulaBridge) GetStatus(txHash string) (*MobulaStatusResponse, error) { + url := fmt.Sprintf("https://api.mobula.io/api/2/bridge/status/%s", txHash) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", m.apiKey) + + resp, err := m.client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(body)) + } + + var status MobulaStatusResponse + if err := json.NewDecoder(resp.Body).Decode(&status); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &status, nil +} + +func (m *MobulaBridge) VerifyRoutes() (*MobulaRoutesResponse, error) { + url := "https://api.mobula.io/api/2/bridge/routes" + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", m.apiKey) + + resp, err := m.client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to execute request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(body)) + } + + var routes MobulaRoutesResponse + if err := json.NewDecoder(resp.Body).Decode(&routes); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &routes, nil +} + +type TestRoute struct { + Name string + FromChain string // Human-readable label (e.g. "Solana") + FromChainAPI string // API-specific id (e.g. "solana:solana") + FromToken string + ToChain string + ToChainAPI string + ToToken string + Amounts []float64 // token-native amounts (decimal) + UsdAmounts []float64 // same amounts expressed in USD (for labels) + IsSolanaSrc bool // true if senderAddress is required + WeeklyOnly bool // true if route should only run weekly (for meme tokens) + QuoteOnly bool // true if route should NEVER be executed (quote loop only — for asymmetric or unsupported destinations like HC where we don't yet have on-chain fill plumbing) +} + +func GetTestRoutes() []TestRoute { + // USDC Triangle (self-balancing): + // R1: Solana USDC → Base USDC + // R2: Base USDC → Arbitrum USDT + // R3: Arbitrum USDT → Solana USDC + // + // Meme Route (separate, weekly only): + // R4: TRUMP (Solana) → BRETT (Base) + // + // TRUMP price is fetched dynamically via TokenPriceUSD (5min cache) so the R4 + // amounts always reflect the real token value — fixes the bug where a stale + // hardcoded $2.87 made our $300-labelled quote actually send $266 worth. + trumpPrice := TokenPriceUSD("TRUMP", 2.55) // current price ~$2.55, fallback if API unreachable + trump5 := 5.0 / trumpPrice + trump50 := 50.0 / trumpPrice + trump300 := 300.0 / trumpPrice + + return []TestRoute{ + // R1: Solana USDC → Base USDC + { + Name: "USDC_SOL_BASE", + FromChain: "Solana", FromChainAPI: "solana:solana", + FromToken: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + ToChain: "Base", ToChainAPI: "evm:8453", + ToToken: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + Amounts: []float64{5, 50, 300}, + UsdAmounts: []float64{5, 50, 300}, + IsSolanaSrc: true, + WeeklyOnly: false, + }, + // R2: Base USDC → Arbitrum USDT + { + Name: "USDC_BASE_USDT_ARB", + FromChain: "Base", FromChainAPI: "evm:8453", + FromToken: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + ToChain: "Arbitrum", ToChainAPI: "evm:42161", + ToToken: "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", + Amounts: []float64{5, 50, 300}, + UsdAmounts: []float64{5, 50, 300}, + IsSolanaSrc: false, + WeeklyOnly: false, + }, + // R3: Arbitrum USDT → Solana USDC (completes the triangle) + { + Name: "USDT_ARB_USDC_SOL", + FromChain: "Arbitrum", FromChainAPI: "evm:42161", + FromToken: "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9", + ToChain: "Solana", ToChainAPI: "solana:solana", + ToToken: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + Amounts: []float64{5, 50, 300}, + UsdAmounts: []float64{5, 50, 300}, + IsSolanaSrc: false, + WeeklyOnly: false, + }, + // R4: TRUMP (Solana) → BRETT (Base) - one-way. + // Amounts computed at startup from live TRUMP price (refreshed every 5min via + // TokenPriceUSD cache). Execution stays weekly at $5 only. + { + Name: "TRUMP_SOL_BRETT_BASE", + FromChain: "Solana", FromChainAPI: "solana:solana", + FromToken: "6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", + ToChain: "Base", ToChainAPI: "evm:8453", + ToToken: "0x532f27101965dd16442E59d40670FaF5eBB142E4", + Amounts: []float64{trump5, trump50, trump300}, + UsdAmounts: []float64{5, 50, 300}, + IsSolanaSrc: true, + WeeklyOnly: true, + }, + // R5: Arbitrum USDC → HyperCore USDC (Hyperliquid perp account). + // One-way deposit benchmark: HL is asymmetric (perp account credit, + // withdraw goes through HL's L1 signed action, not handled here). + // Phase 1 = quote-only — flagged QuoteOnly:true so GetTriangleRoutes() + // excludes it from the $5/$50/$300 execution scheduler. Quote loop + // still runs every 5min, so the dashboard gets latency/fees/cost data + // for Mobula vs Relay vs LiFi on HC. Flip to false in Phase 2 once HL + // balance reader + manual capital seed are in place. + { + Name: "USDC_ARB_HYPERCORE", + FromChain: "Arbitrum", FromChainAPI: "evm:42161", + FromToken: "0xaf88d065e77c8cc2239327c5edb3a432268e5831", + ToChain: "HyperCore", ToChainAPI: "hl:mainnet", + ToToken: "USDC", // Mobula uses symbol; per-bridge translators map to provider-specific addr + Amounts: []float64{5, 50, 300}, + UsdAmounts: []float64{5, 50, 300}, + IsSolanaSrc: false, + WeeklyOnly: false, + QuoteOnly: true, + }, + } +} + +// GetTriangleRoutes returns only the routes that should run in the scheduled +// $5 / $50 / $300 execution cycles — i.e. the USDC triangle (R1, R2, R3). +// Excludes WeeklyOnly (R4 meme) and QuoteOnly (R5 HyperCore — quote loop only). +func GetTriangleRoutes() []TestRoute { + routes := GetTestRoutes() + var triangle []TestRoute + for _, r := range routes { + if r.WeeklyOnly || r.QuoteOnly { + continue + } + triangle = append(triangle, r) + } + return triangle +} + +// GetMemeRoutes returns only the meme routes (R4) +func GetMemeRoutes() []TestRoute { + routes := GetTestRoutes() + var meme []TestRoute + for _, r := range routes { + if r.WeeklyOnly { + meme = append(meme, r) + } + } + return meme +} + +// TestRoute runs a quote against a given route, using amount (token-native) + amountUsd (for labels). +func (m *MobulaBridge) TestRoute(route TestRoute, amount, amountUsd float64, region, solAddress, evmAddress string) { + amountStr := strconv.FormatFloat(amountUsd, 'f', 0, 64) + labels := []string{"mobula", route.FromChain, route.ToChain, route.FromToken, route.ToToken, amountStr, region, route.ToChain} + + // senderAddress is the origin-chain signer/depositor the API needs + // to build the EIP-712 deposit intent. Must be set for any origin, + // not only Solana. Previously empty-defaulted on EVM origins, which + // silently broke Arb -> Sol (no EVM address anywhere in the request, + // API rejects with "senderAddress required" -> quote_failed loop). + senderAddress := evmAddress + walletAddress := evmAddress + if route.IsSolanaSrc { + senderAddress = solAddress + } + if route.ToChain == "Solana" { + walletAddress = solAddress + } + + quote, quoteLatency, err := m.GetQuote( + route.FromChainAPI, + route.FromToken, + route.ToChainAPI, + route.ToToken, + senderAddress, + walletAddress, + amount, + ) + + + if err != nil { + log.Printf("[MOBULA][%s][%.0f USD] ❌ Quote failed: %v", route.Name, amountUsd, err) + bridgeErrors.WithLabelValues(append(labels, "quote_failed")...).Inc() + bridgeQuoteSuccess.WithLabelValues(labels...).Set(0) + return + } + + // Success-only: see debridge_bridge.go, failures must not enter the histogram. + bridgeQuoteLatency.WithLabelValues(labels...).Observe(float64(quoteLatency.Milliseconds())) + + bridgeQuoteSuccess.WithLabelValues(labels...).Set(1) + + outputUSD, _ := strconv.ParseFloat(quote.Data.EstimatedAmountOutUsd, 64) + gasUSD, _ := strconv.ParseFloat(quote.Data.Fees.GasFeeUsd, 64) + bridgeFeeUSD, _ := strconv.ParseFloat(quote.Data.Fees.TotalFeeUsd, 64) + + slippageUSD := amountUsd - outputUSD + if slippageUSD < 0 { + slippageUSD = 0 + } + costUSD := bridgeFeeUSD + slippageUSD + gasUSD + costPercent := 0.0 + if amountUsd > 0 { + costPercent = (costUSD / amountUsd) * 100 + } + + bridgeFeesUSD.WithLabelValues(labels...).Set(bridgeFeeUSD) + bridgeFeesPercent.WithLabelValues(labels...).Set((bridgeFeeUSD / amountUsd) * 100) + bridgeCostUSD.WithLabelValues(labels...).Set(costUSD) + bridgeCostPercent.WithLabelValues(labels...).Set(costPercent) + bridgeSlippageUSD.WithLabelValues(labels...).Set(slippageUSD) + bridgeGasUSD.WithLabelValues(labels...).Set(gasUSD) + bridgeFixFeeUSD.WithLabelValues(labels...).Set(0) // Mobula has no fix fee + bridgeOutputUSD.WithLabelValues(labels...).Set(outputUSD) + bridgeEstimatedTimeMs.WithLabelValues(labels...).Set(float64(quote.Data.EstimatedTimeMs)) + + log.Printf("[MOBULA][%s][%.0f USD] ✅ Quote: %dms | Cost: $%.4f (%.3f%%) | Est: %dms", + route.Name, + amountUsd, + quoteLatency.Milliseconds(), + costUSD, + costPercent, + quote.Data.EstimatedTimeMs, + ) +} diff --git a/harnesses/bridge-monitor/cmd/monitor/nearintents_bridge.go b/harnesses/bridge-monitor/cmd/monitor/nearintents_bridge.go new file mode 100644 index 00000000..d5adf41c --- /dev/null +++ b/harnesses/bridge-monitor/cmd/monitor/nearintents_bridge.go @@ -0,0 +1,262 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strconv" + "strings" + "time" +) + +// NearIntentsBridge integrates Near Intents 1Click API (https://1click.chaindefuser.com) +// into the quote-loop. Solver auction model: the API polls a bus of market makers, +// returns the winning signed bid. Quote latency reflects bid arrival, not route search. +// +// Auth: Authorization: Bearer if apiKey is set, otherwise anonymous (works +// but adds ~10 bps appFee and lower solver priority). The OpenAPI spec documents +// X-API-Key as preferred but it returned 401 in our tests; Bearer is the path +// that actually works for partner JWTs in 2026. +type NearIntentsBridge struct { + client *http.Client + apiKey string +} + +func NewNearIntentsBridge(apiKey string) *NearIntentsBridge { + return &NearIntentsBridge{ + // Server bus times out at 25s. Client gives a 5s margin for network round-trip. + client: &http.Client{Timeout: 30 * time.Second}, + apiKey: strings.TrimSpace(apiKey), + } +} + +// nearIntentsAssetID maps a (chain, tokenAddress) tuple to the Near Intents +// assetId. The harness passes contract addresses (Solana mints + EVM +// contracts), NOT symbols, in route.FromToken / route.ToToken. Match by +// address. EVM addresses are case-insensitive; Solana mints case-sensitive. +// +// HyperCore is the only exception: the harness uses the symbol literal +// "USDC" for ToToken on HC and lets per-bridge translators map to the real +// HC perp address. Match that string directly. +// +// Returns ok=false when the tuple is not supported, so the caller skips +// the route cleanly without polluting bridge_errors_total with a +// quote_failed counter. +func nearIntentsAssetID(chain, tokenAddress string) (string, bool) { + c := strings.ToLower(chain) + addr := strings.ToLower(tokenAddress) + switch c { + case "solana": + // SPL mint (case-sensitive) + if tokenAddress == "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" { + return "nep141:sol-5ce3bf3a31af18be40ba30f721101b4341690186.omft.near", true + } + case "base": + // USDC on Base + if addr == "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913" { + return "nep141:base-0x833589fcd6edb6e08f4c7c32d4f71b54bda02913.omft.near", true + } + case "arbitrum": + // USDC on Arbitrum (native, not USDC.e) + if addr == "0xaf88d065e77c8cc2239327c5edb3a432268e5831" { + return "nep141:arb-0xaf88d065e77c8cc2239327c5edb3a432268e5831.omft.near", true + } + case "hypercore": + // Mobula harness uses the symbol literal "USDC" here, per the + // per-bridge translator convention. Match that string directly. + if tokenAddress == "USDC" { + return "1cs_v1:hypercore:erc20:0xb88339CB7199b77E23DB6E890353E22632Ba630f", true + } + } + return "", false +} + +type NearIntentsQuoteRequest struct { + Dry bool `json:"dry"` + SwapType string `json:"swapType"` + SlippageTolerance int `json:"slippageTolerance"` + OriginAsset string `json:"originAsset"` + DepositType string `json:"depositType"` + DestinationAsset string `json:"destinationAsset"` + Amount string `json:"amount"` + Recipient string `json:"recipient"` + RecipientType string `json:"recipientType"` + RefundTo string `json:"refundTo"` + RefundType string `json:"refundType"` + Deadline string `json:"deadline"` + QuoteWaitingTimeMs int `json:"quoteWaitingTimeMs"` +} + +type NearIntentsQuoteResponse struct { + Quote struct { + AmountIn string `json:"amountIn"` + AmountInFormatted string `json:"amountInFormatted"` + AmountInUsd string `json:"amountInUsd"` + AmountOut string `json:"amountOut"` + AmountOutFormatted string `json:"amountOutFormatted"` + AmountOutUsd string `json:"amountOutUsd"` + // timeEstimate is the solver-reported settlement ETA in seconds. + TimeEstimate float64 `json:"timeEstimate"` + } `json:"quote"` +} + +// solverTimeoutMarker is the prefix used by the 1Click coordinator when no +// market-maker submits a bid inside the auction window. It surfaces as HTTP +// 500 but is a normal "no quote available right now" outcome, not a bug we +// should tag as quote_failed. +const solverTimeoutMarker = "Failed to receive response within timeout" + +func isSolverTimeout(body []byte) bool { + return bytes.Contains(body, []byte(solverTimeoutMarker)) +} + +func (n *NearIntentsBridge) GetQuote(originAsset, destinationAsset, rawAmount, recipient, refundTo string) (*NearIntentsQuoteResponse, time.Duration, []byte, int, error) { + start := time.Now() + // Deadline must be a future ISO-8601 timestamp. The coordinator rejects + // past deadlines outright; we pick 15 min to leave room for the bus loop. + deadline := time.Now().UTC().Add(15 * time.Minute).Format("2006-01-02T15:04:05.000Z") + + body := NearIntentsQuoteRequest{ + Dry: true, + SwapType: "EXACT_INPUT", + SlippageTolerance: 100, // 1% + OriginAsset: originAsset, + DepositType: "ORIGIN_CHAIN", + DestinationAsset: destinationAsset, + Amount: rawAmount, + Recipient: recipient, + RecipientType: "DESTINATION_CHAIN", + RefundTo: refundTo, + RefundType: "ORIGIN_CHAIN", + Deadline: deadline, + QuoteWaitingTimeMs: 3000, + } + payload, err := json.Marshal(body) + if err != nil { + return nil, time.Since(start), nil, 0, fmt.Errorf("near-intents marshal: %w", err) + } + + req, err := http.NewRequest("POST", "https://1click.chaindefuser.com/v0/quote", bytes.NewReader(payload)) + if err != nil { + return nil, time.Since(start), nil, 0, fmt.Errorf("near-intents request build: %w", err) + } + req.Header.Set("Content-Type", "application/json") + if n.apiKey != "" { + req.Header.Set("Authorization", "Bearer "+n.apiKey) + } + + resp, err := n.client.Do(req) + if err != nil { + return nil, time.Since(start), nil, 0, fmt.Errorf("near-intents request: %w", err) + } + defer resp.Body.Close() + + raw, _ := io.ReadAll(resp.Body) + latency := time.Since(start) + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return nil, latency, raw, resp.StatusCode, fmt.Errorf("near-intents %d: %s", resp.StatusCode, truncateNI(string(raw), 300)) + } + + var out NearIntentsQuoteResponse + if err := json.Unmarshal(raw, &out); err != nil { + return nil, latency, raw, resp.StatusCode, fmt.Errorf("near-intents decode: %w", err) + } + return &out, latency, raw, resp.StatusCode, nil +} + +func (n *NearIntentsBridge) TestRoute(route TestRoute, amount, amountUsd float64, rawUnits string, region, solAddress, evmAddress string) { + amountStr := strconv.FormatFloat(amountUsd, 'f', 0, 64) + labels := []string{"near-intents", route.FromChain, route.ToChain, route.FromToken, route.ToToken, amountStr, region, route.ToChain} + + // Resolve origin and destination assetIds. Anything we don't have a + // mapping for is "unsupported_route" — skip silently so the bench stays + // honest (no fake failures inflating the error counter). + originAsset, originOK := nearIntentsAssetID(route.FromChain, route.FromToken) + destAsset, destOK := nearIntentsAssetID(route.ToChain, route.ToToken) + if !originOK || !destOK { + return + } + + // HyperCore is destination-only on Near Intents. The assetID map already + // returns ok=false for HyperCore as source, but keep this guard explicit + // so a future asset-id table extension can't quietly enable an unsupported + // direction. + if strings.EqualFold(route.FromChain, "HyperCore") { + return + } + + // Recipient and refundTo must be valid addresses for the respective chain + // even in dry mode. Use the same wallets we use for live execution; the + // coordinator accepts any well-formed address in dry mode. + recipient := evmAddress + refundTo := evmAddress + if route.ToChain == "Solana" { + recipient = solAddress + } + if route.FromChain == "Solana" { + refundTo = solAddress + } + + quote, quoteLatency, rawBody, httpStatus, err := n.GetQuote(originAsset, destAsset, rawUnits, recipient, refundTo) + + if err != nil { + // Solver auction timeout is a normal "no bid" outcome, not a bug. + // Distinguish in error_type so the page can decide later whether to + // surface availability vs latency separately. + errType := "quote_failed" + if httpStatus == http.StatusInternalServerError && isSolverTimeout(rawBody) { + errType = "solver_timeout" + } + log.Printf("[NEAR-INTENTS][%s][%.0f USD] ❌ %v", route.Name, amountUsd, err) + bridgeErrors.WithLabelValues(append(labels, errType)...).Inc() + bridgeQuoteSuccess.WithLabelValues(labels...).Set(0) + return + } + + // Latency is only meaningful for quotes that returned a usable response + // (same rule as the other bridges). Fast 4xx rejections must not enter + // the histogram or they skew the leader board. + bridgeQuoteLatency.WithLabelValues(labels...).Observe(float64(quoteLatency.Milliseconds())) + bridgeQuoteSuccess.WithLabelValues(labels...).Set(1) + + inUsd, _ := strconv.ParseFloat(quote.Quote.AmountInUsd, 64) + outUsd, _ := strconv.ParseFloat(quote.Quote.AmountOutUsd, 64) + + // Near Intents returns a single net amountOutUsd. The solver bid bakes in + // gas, bridge fee, slippage and protocol cut. We expose the total as + // bridge_cost_usd and leave the breakdown buckets at 0 to make it obvious + // in the data that no per-component decomposition is available from this + // provider. + costUsd := inUsd - outUsd + if costUsd < 0 { + costUsd = 0 + } + costPct := 0.0 + if amountUsd > 0 { + costPct = (costUsd / amountUsd) * 100 + } + + bridgeFeesUSD.WithLabelValues(labels...).Set(0) + bridgeFeesPercent.WithLabelValues(labels...).Set(0) + bridgeCostUSD.WithLabelValues(labels...).Set(costUsd) + bridgeCostPercent.WithLabelValues(labels...).Set(costPct) + bridgeSlippageUSD.WithLabelValues(labels...).Set(0) + bridgeGasUSD.WithLabelValues(labels...).Set(0) + bridgeFixFeeUSD.WithLabelValues(labels...).Set(0) + bridgeOutputUSD.WithLabelValues(labels...).Set(outUsd) + bridgeEstimatedTimeMs.WithLabelValues(labels...).Set(quote.Quote.TimeEstimate * 1000) + + log.Printf("[NEAR-INTENTS][%s][%.0f USD] ✅ Quote: %dms | Cost: $%.4f (%.3f%%) | Est: %.1fs", + route.Name, amountUsd, quoteLatency.Milliseconds(), + costUsd, costPct, quote.Quote.TimeEstimate) +} + +func truncateNI(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "..." +} diff --git a/harnesses/bridge-monitor/cmd/monitor/onchain_balance.go b/harnesses/bridge-monitor/cmd/monitor/onchain_balance.go new file mode 100644 index 00000000..ac450ef1 --- /dev/null +++ b/harnesses/bridge-monitor/cmd/monitor/onchain_balance.go @@ -0,0 +1,223 @@ +package main + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "math/big" + "strings" + "time" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/rpc" +) + +// erc20BalanceOf calls balanceOf(owner) on an ERC-20 contract via the chain's +// ethclient. Returns the raw token amount (no decimals applied). +func (tx *TxExecutor) erc20BalanceOf(chain string, token, owner common.Address) (*big.Int, error) { + client := tx.evmClientFor(chain) + if client == nil { + return nil, fmt.Errorf("unknown EVM chain: %s", chain) + } + + // keccak256("balanceOf(address)")[:4] = 0x70a08231 + data := append(hexutil.MustDecode("0x70a08231"), common.LeftPadBytes(owner.Bytes(), 32)...) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + out, err := client.CallContract(ctx, ethereum.CallMsg{To: &token, Data: data}, nil) + if err != nil { + return nil, err + } + if len(out) == 0 { + return big.NewInt(0), nil + } + return new(big.Int).SetBytes(out), nil +} + +// evmClientFor maps a Route chain name (Base / Arbitrum) to the cached ethclient. +func (tx *TxExecutor) evmClientFor(chain string) interface{ CallContract(context.Context, ethereum.CallMsg, *big.Int) ([]byte, error) } { + switch strings.ToLower(chain) { + case "base": + return tx.baseClient + case "arbitrum": + return tx.arbitrumClient + } + return nil +} + +// solanaSPLBalanceOf returns the SPL token balance for `owner` and `mint`. If +// the associated token account does not yet exist (never received this token), +// returns 0 — that's a valid pre-execution state. +func (tx *TxExecutor) solanaSPLBalanceOf(owner solana.PublicKey, mint solana.PublicKey) (*big.Int, error) { + if tx.solanaClient == nil { + return nil, fmt.Errorf("solana client not initialized") + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + out, err := tx.solanaClient.GetTokenAccountsByOwner(ctx, owner, &rpc.GetTokenAccountsConfig{Mint: &mint}, &rpc.GetTokenAccountsOpts{Commitment: rpc.CommitmentConfirmed}) + if err != nil { + return nil, err + } + if out == nil || len(out.Value) == 0 { + return big.NewInt(0), nil + } + // Sum across all token accounts owned by owner for that mint (usually 1). + total := new(big.Int) + for _, acc := range out.Value { + raw := acc.Account.Data.GetBinary() + // SPL TokenAccount layout: amount is u64 LE at bytes [64..72] + if len(raw) >= 72 { + amount := new(big.Int).SetUint64(uint64leAt(raw[64:72])) + total.Add(total, amount) + } + } + return total, nil +} + +func uint64leAt(b []byte) uint64 { + return uint64(b[0]) | uint64(b[1])<<8 | uint64(b[2])<<16 | uint64(b[3])<<24 | + uint64(b[4])<<32 | uint64(b[5])<<40 | uint64(b[6])<<48 | uint64(b[7])<<56 +} + +// solanaNativeBalance returns the lamport balance of `owner`. +func (tx *TxExecutor) solanaNativeBalance(owner solana.PublicKey) (*big.Int, error) { + if tx.solanaClient == nil { + return nil, fmt.Errorf("solana client not initialized") + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + out, err := tx.solanaClient.GetBalance(ctx, owner, rpc.CommitmentConfirmed) + if err != nil { + return nil, err + } + return new(big.Int).SetUint64(out.Value), nil +} + +// readDestinationBalance returns the balance of the destination token at the +// receiver address, in raw token units (no decimals applied). Used pre/post +// execution to compute the realized fill (not the quote estimate). +// +// For Solana destination: looks up the SPL token account by mint. For "native" +// SOL, pass mint == "So11111111111111111111111111111111111111112" or empty — +// we'll use getBalance instead. +// +// For EVM destination (Base / Arbitrum): standard ERC-20 balanceOf. +func (tx *TxExecutor) readDestinationBalance(chain, tokenAddrOrSymbol, owner string) (*big.Int, error) { + chainLower := strings.ToLower(chain) + if chainLower == "solana" { + ownerKey, err := solana.PublicKeyFromBase58(owner) + if err != nil { + return nil, fmt.Errorf("invalid solana owner: %w", err) + } + // Native SOL when token is the wrapped-SOL mint, "SOL", or empty. + if tokenAddrOrSymbol == "" || strings.EqualFold(tokenAddrOrSymbol, "SOL") || + tokenAddrOrSymbol == "So11111111111111111111111111111111111111112" { + return tx.solanaNativeBalance(ownerKey) + } + mint, err := solana.PublicKeyFromBase58(tokenAddrOrSymbol) + if err != nil { + return nil, fmt.Errorf("invalid solana mint: %w", err) + } + return tx.solanaSPLBalanceOf(ownerKey, mint) + } + // EVM + if !strings.HasPrefix(tokenAddrOrSymbol, "0x") { + return nil, fmt.Errorf("EVM destination token must be a hex address, got %q", tokenAddrOrSymbol) + } + return tx.erc20BalanceOf(chain, common.HexToAddress(tokenAddrOrSymbol), common.HexToAddress(owner)) +} + +// rawDelta returns (after - before) clamped to >= 0. +func rawDelta(after, before *big.Int) *big.Int { + if after == nil || before == nil { + return big.NewInt(0) + } + d := new(big.Int).Sub(after, before) + if d.Sign() < 0 { + return big.NewInt(0) + } + return d +} + +// rawToFloat divides a raw token amount by 10^decimals. +func rawToFloat(raw *big.Int, decimals int) float64 { + if raw == nil { + return 0 + } + f, _ := new(big.Float).Quo(new(big.Float).SetInt(raw), big.NewFloat(pow10(decimals))).Float64() + return f +} + +func pow10(n int) float64 { + v := 1.0 + for i := 0; i < n; i++ { + v *= 10 + } + return v +} + +// Unused encoders kept around to avoid removing imports if we extend the file. +var _ = base64.StdEncoding +var _ = json.Marshal + +// destinationUSDPerToken returns the USD price of one unit of the destination +// token. For stablecoins assumes parity ($1). For known meme tokens uses the +// live price cache. New non-stable destinations need to be added here. +func destinationUSDPerToken(route TestRoute) float64 { + tok := strings.ToUpper(route.ToToken) + switch { + case strings.Contains(tok, "BRETT"), + strings.HasPrefix(tok, "0X532F27101965DD16442E59D40670FAF5EBB142E4"): + return TokenPriceUSD("BRETT", 0.0072) + case strings.Contains(tok, "TRUMP"), + strings.HasPrefix(tok, "6P6XGHYF7AEE6TZKSMFSKO444WQOP15ICUSQI2JFGIPN"): + return TokenPriceUSD("TRUMP", 2.55) + } + // Default: stablecoin (USDC, USDT) → $1 + return 1.0 +} + +// destinationTokenDecimals returns the decimals used by the destination token. +// Covers everything we currently bridge to (USDC/USDT 6 dec, BRETT 18 dec). +// Falls back to 6 for unknown destinations — safe default for stables. +func destinationTokenDecimals(route TestRoute) int { + tok := strings.ToUpper(route.ToToken) + switch { + case strings.Contains(tok, "BRETT"), + strings.HasPrefix(tok, "0X532F27101965DD16442E59D40670FAF5EBB142E4"): // BRETT on Base + return 18 + case strings.Contains(tok, "USDC"), strings.Contains(tok, "USDT"), + strings.HasPrefix(tok, "0XAF88D065"), // Arb USDC native + strings.HasPrefix(tok, "0XFD086BC7"), // Arb USDT0 + strings.HasPrefix(tok, "0X833589FC"), // Base USDC + strings.HasPrefix(tok, "EPJFW"): // Solana USDC + return 6 + } + return 6 +} + +// pollRealizedFill polls the destination balance until it differs from +// `before` by at least 1 raw unit, up to `timeout`. Returns the post-balance +// once movement is detected, or the latest read on timeout (caller decides +// whether to trust it). +func (tx *TxExecutor) pollRealizedFill(chain, token, owner string, before *big.Int, timeout time.Duration) (*big.Int, error) { + deadline := time.Now().Add(timeout) + var last *big.Int = before + for time.Now().Before(deadline) { + now, err := tx.readDestinationBalance(chain, token, owner) + if err != nil { + time.Sleep(1500 * time.Millisecond) + continue + } + last = now + if before == nil || now.Cmp(before) > 0 { + return now, nil + } + time.Sleep(1500 * time.Millisecond) + } + return last, fmt.Errorf("fill not visible within %s", timeout) +} diff --git a/harnesses/bridge-monitor/cmd/monitor/pricer.go b/harnesses/bridge-monitor/cmd/monitor/pricer.go new file mode 100644 index 00000000..024aac08 --- /dev/null +++ b/harnesses/bridge-monitor/cmd/monitor/pricer.go @@ -0,0 +1,78 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "sync" + "time" +) + +// pricer fetches and caches USD spot prices for tokens we benchmark in non-stable +// quantities (currently just TRUMP for R4). Avoids the previous footgun where a +// hardcoded $2.87/TRUMP made our $300-labelled quotes actually send $266 worth, +// reporting bogus $33 of "slippage". + +var ( + priceMu sync.RWMutex + priceCache = map[string]priceEntry{} + priceHTTP = &http.Client{Timeout: 10 * time.Second} +) + +type priceEntry struct { + value float64 + fetchedAt time.Time +} + +// 5min TTL: Mobula market data is fast, no point hitting it harder than the quote loop. +const priceTTL = 5 * time.Minute + +// TokenPriceUSD returns the cached USD price for a symbol. On miss or staleness +// it fetches from Mobula. Returns `fallback` if the fetch fails (network, parse +// error, zero price) so the monitor never blocks on price discovery. +func TokenPriceUSD(symbol string, fallback float64) float64 { + priceMu.RLock() + if e, ok := priceCache[symbol]; ok && time.Since(e.fetchedAt) < priceTTL { + priceMu.RUnlock() + return e.value + } + priceMu.RUnlock() + + p := fetchPriceUSD(symbol) + if p <= 0 { + return fallback + } + priceMu.Lock() + priceCache[symbol] = priceEntry{value: p, fetchedAt: time.Now()} + priceMu.Unlock() + return p +} + +func fetchPriceUSD(symbol string) float64 { + apiKey := os.Getenv("MOBULA_API_KEY") + url := fmt.Sprintf("https://api.mobula.io/api/1/market/data?symbol=%s", symbol) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return 0 + } + if apiKey != "" { + req.Header.Set("Authorization", apiKey) + } + resp, err := priceHTTP.Do(req) + if err != nil { + return 0 + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + var r struct { + Data struct { + Price float64 `json:"price"` + } `json:"data"` + } + if err := json.Unmarshal(body, &r); err != nil { + return 0 + } + return r.Data.Price +} diff --git a/harnesses/bridge-monitor/cmd/monitor/relay_bridge.go b/harnesses/bridge-monitor/cmd/monitor/relay_bridge.go new file mode 100644 index 00000000..908bfbcb --- /dev/null +++ b/harnesses/bridge-monitor/cmd/monitor/relay_bridge.go @@ -0,0 +1,238 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "strconv" + "strings" + "time" +) + +type RelayBridge struct { + client *http.Client +} + +func NewRelayBridge() *RelayBridge { + return &RelayBridge{client: &http.Client{Timeout: 45 * time.Second}} +} + +// Chain ID mapping for Relay (uses custom numeric IDs) +func relayChainID(chain string) int64 { + switch strings.ToLower(chain) { + case "solana": + return 792703809 + case "base": + return 8453 + case "arbitrum": + return 42161 + case "hypercore", "hyperliquid": + return 1337 + } + return 0 +} + +// relayDestToken translates the abstract ToToken from a TestRoute into the +// destination address Relay expects on that chain. HyperCore's USDC (Perps) +// uses the special zero address per Relay's /currencies/v2. +func relayDestToken(route TestRoute) string { + if route.ToChain == "HyperCore" && strings.EqualFold(route.ToToken, "USDC") { + return "0x00000000000000000000000000000000" + } + if route.ToChain == "Solana" { + return route.ToToken // SPL mint, case-sensitive + } + return strings.ToLower(route.ToToken) +} + +type RelayQuoteRequest struct { + User string `json:"user"` + OriginChainID int64 `json:"originChainId"` + DestinationChainID int64 `json:"destinationChainId"` + OriginCurrency string `json:"originCurrency"` + DestinationCurrency string `json:"destinationCurrency"` + Amount string `json:"amount"` + TradeType string `json:"tradeType"` + Recipient string `json:"recipient"` +} + +// RelaySolanaInstruction represents a Solana instruction from Relay +type RelaySolanaInstruction struct { + Keys []struct { + Pubkey string `json:"pubkey"` + IsSigner bool `json:"isSigner"` + IsWritable bool `json:"isWritable"` + } `json:"keys"` + ProgramId string `json:"programId"` + Data string `json:"data"` // hex-encoded instruction data +} + +// RelayStepData can be EVM tx data or Solana instructions +type RelayStepData struct { + // EVM fields + To string `json:"to"` + Data string `json:"data"` + Value string `json:"value"` + // Solana fields + Instructions []RelaySolanaInstruction `json:"instructions"` + AddressLookupTableAddresses []string `json:"addressLookupTableAddresses"` +} + +type RelayQuoteResponse struct { + Details struct { + CurrencyIn struct { + AmountUsd string `json:"amountUsd"` + } `json:"currencyIn"` + CurrencyOut struct { + AmountUsd string `json:"amountUsd"` + } `json:"currencyOut"` + TotalImpact struct { + USD string `json:"usd"` + } `json:"totalImpact"` + TimeEstimate float64 `json:"timeEstimate"` + } `json:"details"` + Fees struct { + Gas struct{ AmountUsd string `json:"amountUsd"` } `json:"gas"` + RelayerGas struct{ AmountUsd string `json:"amountUsd"` } `json:"relayerGas"` + RelayerService struct{ AmountUsd string `json:"amountUsd"` } `json:"relayerService"` + } `json:"fees"` + // Transaction steps for execution (EVM or Solana) + Steps []struct { + ID string `json:"id"` + RequestId string `json:"requestId"` // Request ID for status polling + Items []struct { + Data RelayStepData `json:"data"` + Check struct { + Endpoint string `json:"endpoint"` // Full polling URL + } `json:"check"` + } `json:"items"` + } `json:"steps"` +} + +func (r *RelayBridge) GetQuote(route TestRoute, rawAmount string, senderAddress, receiverAddress string) (*RelayQuoteResponse, time.Duration, error) { + start := time.Now() + + // Currency case: Solana uses full token addresses, EVM uses lowercase symbols. + // HyperCore needs special "0x00...0" for USDC perp. + originCurrency := strings.ToLower(route.FromToken) + destinationCurrency := relayDestToken(route) + if route.IsSolanaSrc { + originCurrency = route.FromToken + } + + body := RelayQuoteRequest{ + User: senderAddress, + OriginChainID: relayChainID(route.FromChain), + DestinationChainID: relayChainID(route.ToChain), + OriginCurrency: originCurrency, + DestinationCurrency: destinationCurrency, + Amount: rawAmount, + TradeType: "EXACT_INPUT", + Recipient: receiverAddress, + } + bodyBytes, _ := json.Marshal(body) + + resp, err := r.client.Post("https://api.relay.link/quote", "application/json", bytes.NewReader(bodyBytes)) + if err != nil { + return nil, time.Since(start), fmt.Errorf("relay request: %w", err) + } + defer resp.Body.Close() + + raw, _ := io.ReadAll(resp.Body) + latency := time.Since(start) + if resp.StatusCode != http.StatusOK { + return nil, latency, fmt.Errorf("relay %d: %s", resp.StatusCode, string(raw)) + } + + // Debug: log raw response for Solana routes (gate behind env var to avoid + // log flooding — every Solana quote dumps ~500 chars × 12 routes per 5 min). + if route.IsSolanaSrc && os.Getenv("RELAY_DEBUG") == "true" { + log.Printf("[relay-debug] Solana route response (first 500 chars): %s", truncate(string(raw), 500)) + } + + var out RelayQuoteResponse + if err := json.Unmarshal(raw, &out); err != nil { + return nil, latency, fmt.Errorf("relay decode: %w", err) + } + return &out, latency, nil +} + +func (r *RelayBridge) TestRoute(route TestRoute, amount, amountUsd float64, rawUnits string, region, solAddress, evmAddress string) { + amountStr := strconv.FormatFloat(amountUsd, 'f', 0, 64) + labels := []string{"relay", route.FromChain, route.ToChain, route.FromToken, route.ToToken, amountStr, region, route.ToChain} + + // Determine sender/receiver based on source/dest chains + senderAddress := evmAddress + receiverAddress := evmAddress + if route.FromChain == "Solana" { + senderAddress = solAddress + } + if route.ToChain == "Solana" { + receiverAddress = solAddress + } + + quote, quoteLatency, err := r.GetQuote(route, rawUnits, senderAddress, receiverAddress) + + if err != nil { + log.Printf("[RELAY][%s][%.0f USD] ❌ %v", route.Name, amountUsd, err) + bridgeErrors.WithLabelValues(append(labels, "quote_failed")...).Inc() + bridgeQuoteSuccess.WithLabelValues(labels...).Set(0) + return + } + + // Latency is only meaningful for quotes that returned a usable route + // (the published methodology measures exactly that). Fast failures, e.g. + // Cloudflare 403s answered in 30ms, must not enter the histogram: they + // made deBridge look 15x faster the moment its API started rejecting us. + bridgeQuoteLatency.WithLabelValues(labels...).Observe(float64(quoteLatency.Milliseconds())) + bridgeQuoteSuccess.WithLabelValues(labels...).Set(1) + + inUsd, _ := strconv.ParseFloat(quote.Details.CurrencyIn.AmountUsd, 64) + outUsd, _ := strconv.ParseFloat(quote.Details.CurrencyOut.AmountUsd, 64) + impact, _ := strconv.ParseFloat(quote.Details.TotalImpact.USD, 64) + gasUsd, _ := strconv.ParseFloat(quote.Fees.Gas.AmountUsd, 64) + relayerGas, _ := strconv.ParseFloat(quote.Fees.RelayerGas.AmountUsd, 64) + relayerSvc, _ := strconv.ParseFloat(quote.Fees.RelayerService.AmountUsd, 64) + + costUsd := -impact // impact is negative in Relay response + if costUsd == 0 { + costUsd = (inUsd - outUsd) + } + if costUsd < 0 { + costUsd = 0 + } + bridgeFeesOnly := relayerSvc + relayerGas + slippage := costUsd - bridgeFeesOnly - gasUsd + if slippage < 0 { + slippage = 0 + } + costPct := 0.0 + if amountUsd > 0 { + costPct = (costUsd / amountUsd) * 100 + } + + bridgeFeesUSD.WithLabelValues(labels...).Set(bridgeFeesOnly) + bridgeFeesPercent.WithLabelValues(labels...).Set((bridgeFeesOnly / amountUsd) * 100) + bridgeCostUSD.WithLabelValues(labels...).Set(costUsd) + bridgeCostPercent.WithLabelValues(labels...).Set(costPct) + bridgeSlippageUSD.WithLabelValues(labels...).Set(slippage) + bridgeGasUSD.WithLabelValues(labels...).Set(gasUsd) + bridgeFixFeeUSD.WithLabelValues(labels...).Set(0) + bridgeOutputUSD.WithLabelValues(labels...).Set(outUsd) + bridgeEstimatedTimeMs.WithLabelValues(labels...).Set(float64(quote.Details.TimeEstimate * 1000)) + + log.Printf("[RELAY][%s][%.0f USD] ✅ Quote: %dms | Cost: $%.4f (%.3f%%) | Est: %.1fs", + route.Name, amountUsd, quoteLatency.Milliseconds(), + costUsd, costPct, quote.Details.TimeEstimate) +} + +func truncate(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "..." +} diff --git a/harnesses/bridge-monitor/cmd/monitor/scheduler.go b/harnesses/bridge-monitor/cmd/monitor/scheduler.go new file mode 100644 index 00000000..ca7c6f3e --- /dev/null +++ b/harnesses/bridge-monitor/cmd/monitor/scheduler.go @@ -0,0 +1,232 @@ +package main + +import ( + "log" + "time" +) + +// Schedule defines when execution tests run +// Uses fixed UTC times so redeploys don't affect the rhythm +type Schedule struct { + // $5 tests: daily at these hours UTC + Hours5USD []int // e.g., [10] = 10:00 UTC daily + + // $50 tests: specific weekdays at hour UTC + // 0=Sunday, 1=Monday, ..., 6=Saturday + Weekdays50USD []time.Weekday + Hour50USD int + + // $300 tests: specific weekdays at hour UTC (Option C — weekly rhythm so + // stats converge in ~6 weeks instead of 6 months vs the monthly cadence). + Weekdays300USD []time.Weekday + Hour300USD int +} + +// DefaultSchedule returns the default execution schedule +// $5: daily at 10:00 UTC +// $50: Monday & Thursday at 10:00 UTC +// $300: Monday at 10:00 UTC (weekly) +func DefaultSchedule() *Schedule { + return &Schedule{ + Hours5USD: []int{10}, // 10:00 UTC daily + Weekdays50USD: []time.Weekday{time.Monday, time.Thursday}, + Hour50USD: 10, + Weekdays300USD: []time.Weekday{time.Monday}, + Hour300USD: 10, + } +} + +// Scheduler handles fixed-time execution scheduling +type Scheduler struct { + schedule *Schedule + exec5Chan chan struct{} + exec50Chan chan struct{} + exec300Chan chan struct{} + stopChan chan struct{} +} + +// NewScheduler creates a new scheduler with fixed times +func NewScheduler(schedule *Schedule) *Scheduler { + if schedule == nil { + schedule = DefaultSchedule() + } + return &Scheduler{ + schedule: schedule, + exec5Chan: make(chan struct{}, 1), + exec50Chan: make(chan struct{}, 1), + exec300Chan: make(chan struct{}, 1), + stopChan: make(chan struct{}), + } +} + +// Start begins the scheduler goroutines +func (s *Scheduler) Start() { + log.Println("📅 Scheduler started with fixed UTC times:") + log.Printf(" $5 tests: daily at %v:00 UTC", s.schedule.Hours5USD) + log.Printf(" $50 tests: %v at %02d:00 UTC", s.schedule.Weekdays50USD, s.schedule.Hour50USD) + log.Printf(" $300 tests: %v at %02d:00 UTC", s.schedule.Weekdays300USD, s.schedule.Hour300USD) + + // Log next scheduled times + s.logNextScheduledTimes() + + go s.run5USDScheduler() + go s.run50USDScheduler() + go s.run300USDScheduler() +} + +// logNextScheduledTimes logs when the next tests will run +func (s *Scheduler) logNextScheduledTimes() { + now := time.Now().UTC() + + next5 := s.nextTime5USD(now) + next50 := s.nextTime50USD(now) + next300 := s.nextTime300USD(now) + + log.Printf(" Next $5 test: %s (in %v)", next5.Format("2006-01-02 15:04 UTC"), next5.Sub(now).Round(time.Minute)) + log.Printf(" Next $50 test: %s (in %v)", next50.Format("2006-01-02 15:04 UTC"), next50.Sub(now).Round(time.Minute)) + log.Printf(" Next $300 test: %s (in %v)", next300.Format("2006-01-02 15:04 UTC"), next300.Sub(now).Round(time.Minute)) +} + +// Exec5Chan returns the channel that fires for $5 tests +func (s *Scheduler) Exec5Chan() <-chan struct{} { + return s.exec5Chan +} + +// Exec50Chan returns the channel that fires for $50 tests +func (s *Scheduler) Exec50Chan() <-chan struct{} { + return s.exec50Chan +} + +// Exec300Chan returns the channel that fires for $300 tests +func (s *Scheduler) Exec300Chan() <-chan struct{} { + return s.exec300Chan +} + +// Stop stops the scheduler +func (s *Scheduler) Stop() { + close(s.stopChan) +} + +// run5USDScheduler runs $5 tests at scheduled hours daily +func (s *Scheduler) run5USDScheduler() { + for { + now := time.Now().UTC() + next := s.nextTime5USD(now) + waitDuration := next.Sub(now) + + log.Printf("⏰ $5 test scheduled for %s (waiting %v)", next.Format("2006-01-02 15:04 UTC"), waitDuration.Round(time.Minute)) + + select { + case <-time.After(waitDuration): + select { + case s.exec5Chan <- struct{}{}: + log.Println("🔔 $5 execution triggered") + default: + // Channel full, skip + } + case <-s.stopChan: + return + } + } +} + +// run50USDScheduler runs $50 tests on specific weekdays +func (s *Scheduler) run50USDScheduler() { + for { + now := time.Now().UTC() + next := s.nextTime50USD(now) + waitDuration := next.Sub(now) + + log.Printf("⏰ $50 test scheduled for %s (waiting %v)", next.Format("2006-01-02 15:04 UTC"), waitDuration.Round(time.Minute)) + + select { + case <-time.After(waitDuration): + select { + case s.exec50Chan <- struct{}{}: + log.Println("🔔 $50 execution triggered") + default: + } + case <-s.stopChan: + return + } + } +} + +// run300USDScheduler runs $300 tests on specific weekdays +func (s *Scheduler) run300USDScheduler() { + for { + now := time.Now().UTC() + next := s.nextTime300USD(now) + waitDuration := next.Sub(now) + + log.Printf("⏰ $300 test scheduled for %s (waiting %v)", next.Format("2006-01-02 15:04 UTC"), waitDuration.Round(time.Minute)) + + select { + case <-time.After(waitDuration): + select { + case s.exec300Chan <- struct{}{}: + log.Println("🔔 $300 execution triggered") + default: + } + case <-s.stopChan: + return + } + } +} + +// nextTime5USD calculates the next $5 test time +func (s *Scheduler) nextTime5USD(now time.Time) time.Time { + // Find the next scheduled hour today or tomorrow + for _, hour := range s.schedule.Hours5USD { + candidate := time.Date(now.Year(), now.Month(), now.Day(), hour, 0, 0, 0, time.UTC) + if candidate.After(now) { + return candidate + } + } + // All hours passed today, try first hour tomorrow + tomorrow := now.AddDate(0, 0, 1) + return time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), s.schedule.Hours5USD[0], 0, 0, 0, time.UTC) +} + +// nextTime50USD calculates the next $50 test time +func (s *Scheduler) nextTime50USD(now time.Time) time.Time { + // Start from today + candidate := time.Date(now.Year(), now.Month(), now.Day(), s.schedule.Hour50USD, 0, 0, 0, time.UTC) + + // If today's time has passed, start from tomorrow + if !candidate.After(now) { + candidate = candidate.AddDate(0, 0, 1) + } + + // Find next matching weekday + for i := 0; i < 7; i++ { + for _, wd := range s.schedule.Weekdays50USD { + if candidate.Weekday() == wd { + return candidate + } + } + candidate = candidate.AddDate(0, 0, 1) + } + + return candidate +} + +// nextTime300USD calculates the next $300 test time (weekly weekday-based) +func (s *Scheduler) nextTime300USD(now time.Time) time.Time { + candidate := time.Date(now.Year(), now.Month(), now.Day(), s.schedule.Hour300USD, 0, 0, 0, time.UTC) + + if !candidate.After(now) { + candidate = candidate.AddDate(0, 0, 1) + } + + for i := 0; i < 7; i++ { + for _, wd := range s.schedule.Weekdays300USD { + if candidate.Weekday() == wd { + return candidate + } + } + candidate = candidate.AddDate(0, 0, 1) + } + + return candidate +} diff --git a/harnesses/bridge-monitor/cmd/monitor/slack.go b/harnesses/bridge-monitor/cmd/monitor/slack.go new file mode 100644 index 00000000..7b9422f1 --- /dev/null +++ b/harnesses/bridge-monitor/cmd/monitor/slack.go @@ -0,0 +1,300 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "time" +) + +type SlackNotifier struct { + webhookURL string + client *http.Client + apiKey string + evmAddress string + solAddress string +} + +func NewSlackNotifier(webhookURL, apiKey, evmAddress, solAddress string) *SlackNotifier { + if webhookURL == "" { + return nil + } + return &SlackNotifier{ + webhookURL: webhookURL, + client: &http.Client{Timeout: 10 * time.Second}, + apiKey: apiKey, + evmAddress: evmAddress, + solAddress: solAddress, + } +} + +type SlackMessage struct { + Text string `json:"text,omitempty"` + Blocks []SlackBlock `json:"blocks,omitempty"` +} + +type SlackBlock struct { + Type string `json:"type"` + Text *SlackText `json:"text,omitempty"` +} + +type SlackText struct { + Type string `json:"type"` + Text string `json:"text"` +} + +// NotifyBridgeExecution sends a Slack notification after a bridge execution +func (s *SlackNotifier) NotifyBridgeExecution(result *ExecutionResult) error { + if s == nil { + return nil + } + + // Status emoji + statusEmoji := "✅" + statusText := "SUCCESS" + if !result.Success { + statusEmoji = "❌" + statusText = "FAILED" + if result.Reverted { + statusEmoji = "🔄" + statusText = "REVERTED" + } + } + + // Format latencies + quoteLatency := fmt.Sprintf("%.0fms", float64(result.QuoteLatencyMs)) + execLatency := "N/A" + e2eLatency := "N/A" + if result.ExecutionLatencyMs > 0 { + execLatency = fmt.Sprintf("%.1fs", float64(result.ExecutionLatencyMs)/1000) + } + if result.E2ELatencyMs > 0 { + e2eLatency = fmt.Sprintf("%.1fs", float64(result.E2ELatencyMs)/1000) + } + + // Get current balances + balances := s.getBalanceSummary() + + // Error details (for failed executions, include raw JSON for debugging) + errorSection := "" + if !result.Success && result.Error != nil { + errMsg := result.Error.Error() + if len(errMsg) > 1500 { + errMsg = errMsg[:1500] + "... (truncated)" + } + errorSection = fmt.Sprintf("\n*🔍 Error Details:*\n```%s```\n", errMsg) + } + + // Build message + message := fmt.Sprintf(`%s *Bridge Execution: %s* + +*Route:* %s → %s +*Tokens:* %s → %s +*Amount:* $%.2f +*Bridge:* %s + +*Latencies:* +• Quote: %s +• Execution: %s +• End-to-End: %s + +*Costs:* +• Sent: $%.2f +• Filled: $%.4f +• Fees: $%.4f (%.2f%%) +• Total Cost: $%.4f + +*TX:* %s +%s +━━━━━━━━━━━━━━━━━━━━━ +%s`, + statusEmoji, + statusText, + result.FromChain, + result.ToChain, + result.FromToken, + result.ToToken, + result.AmountUSD, + result.Bridge, + quoteLatency, + execLatency, + e2eLatency, + result.AmountUSD, + result.OutputUSD, + result.FeesUSD, + result.FeesPercent, + result.CostUSD, + result.TxHash, + errorSection, + balances, + ) + + return s.send(message) +} + +// NotifyDailySummary sends a daily summary of all wallets +func (s *SlackNotifier) NotifyDailySummary() error { + if s == nil { + return nil + } + + balances := s.getBalanceSummary() + message := fmt.Sprintf("📊 *Daily Wallet Summary*\n\n%s", balances) + return s.send(message) +} + +// getBalanceSummary fetches and formats wallet balances +func (s *SlackNotifier) getBalanceSummary() string { + var summary string + + // Fetch Solana balances + solBalances := s.fetchBalances(s.solAddress, "solana") + if solBalances != "" { + summary += "*🟣 Solana Wallet:*\n" + solBalances + "\n" + } + + // Fetch Base balances + baseBalances := s.fetchBalances(s.evmAddress, "Base") + if baseBalances != "" { + summary += "*🔵 Base Wallet:*\n" + baseBalances + "\n" + } + + // Fetch Arbitrum balances + arbBalances := s.fetchBalances(s.evmAddress, "Arbitrum") + if arbBalances != "" { + summary += "*🟠 Arbitrum Wallet:*\n" + arbBalances + } + + if summary == "" { + summary = "_Could not fetch balances_" + } + + return summary +} + +// fetchBalances calls Mobula API to get wallet balances +func (s *SlackNotifier) fetchBalances(wallet, blockchain string) string { + url := fmt.Sprintf("https://api.mobula.io/api/1/wallet/portfolio?wallet=%s&blockchains=%s", wallet, blockchain) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return "" + } + req.Header.Set("Authorization", "Bearer "+s.apiKey) + + resp, err := s.client.Do(req) + if err != nil { + return "" + } + defer resp.Body.Close() + + var result struct { + Data struct { + Assets []struct { + Asset struct { + Symbol string `json:"symbol"` + } `json:"asset"` + TokenBalance float64 `json:"token_balance"` + EstimatedBalance float64 `json:"estimated_balance"` + } `json:"assets"` + } `json:"data"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "" + } + + var lines string + for _, asset := range result.Data.Assets { + if asset.EstimatedBalance > 0.01 { + lines += fmt.Sprintf("• %s: %.4f ($%.2f)\n", asset.Asset.Symbol, asset.TokenBalance, asset.EstimatedBalance) + } + } + + return lines +} + +// send posts a message to Slack +func (s *SlackNotifier) send(text string) error { + msg := SlackMessage{Text: text} + body, err := json.Marshal(msg) + if err != nil { + return err + } + + resp, err := s.client.Post(s.webhookURL, "application/json", bytes.NewReader(body)) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("slack returned %d", resp.StatusCode) + } + + return nil +} + +// NotifyScheduledSkip notifies when a scheduled tier was not executed (e.g. insufficient +// funds on source chain, balance API down, daily spend limit reached). Fired from the +// executor so each tick produces at most one Slack per (route, amount, reason). +func (s *SlackNotifier) NotifyScheduledSkip(routeName, fromChain, fromToken string, amountUSD float64, reason string) error { + if s == nil { + return nil + } + balances := s.getBalanceSummary() + message := fmt.Sprintf(`⏭️ *Scheduled Execution SKIPPED* + +*Route:* %s +*Chain:* %s +*Token:* %s +*Amount:* $%.2f + +*Reason:* %s + +%s`, + routeName, fromChain, fromToken, amountUSD, reason, balances, + ) + return s.send(message) +} + +// NotifyTierSkipped fires once per scheduler tick when a whole tier cycle cannot +// complete (pre-flight simulation failed). Replaces the per-route skip spam. +func (s *SlackNotifier) NotifyTierSkipped(tier float64, tierLabel, reason string) error { + if s == nil { + return nil + } + balances := s.getBalanceSummary() + message := fmt.Sprintf(`⏭️ *Tier $%.0f (%s) — COULDN'T RUN* + +*Reason:* %s + +Waiting for next scheduled slot. Top up the blocked leg to unblock. + +%s`, + tier, tierLabel, reason, balances, + ) + return s.send(message) +} + +// NotifyStartup sends a startup notification +func (s *SlackNotifier) NotifyStartup(mode string) error { + if s == nil { + return nil + } + + balances := s.getBalanceSummary() + message := fmt.Sprintf(`🚀 *Bridge Benchmark Started* + +*Mode:* %s +*Time:* %s + +%s`, + mode, + time.Now().UTC().Format("2006-01-02 15:04:05 UTC"), + balances, + ) + + return s.send(message) +} diff --git a/harnesses/bridge-monitor/cmd/monitor/tx_executor.go b/harnesses/bridge-monitor/cmd/monitor/tx_executor.go new file mode 100644 index 00000000..0e51c1bf --- /dev/null +++ b/harnesses/bridge-monitor/cmd/monitor/tx_executor.go @@ -0,0 +1,585 @@ +package main + +import ( + "context" + "crypto/ecdsa" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log" + "math/big" + "net/http" + "os" + "strings" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/gagliardetto/solana-go" + "github.com/gagliardetto/solana-go/rpc" +) + +// TxExecutor handles real transaction signing and broadcasting +type TxExecutor struct { + // Solana + solanaClient *rpc.Client + solanaPrivateKey solana.PrivateKey + + // EVM clients (Base, Arbitrum) + baseClient *ethclient.Client + arbitrumClient *ethclient.Client + evmPrivateKey *ecdsa.PrivateKey + evmAddress common.Address + + // HTTP client for status polling + httpClient *http.Client + + // API keys for status polling + mobulaAPIKey string + + // Config + dryRun bool +} + +// Default public RPCs. Override via SOLANA_RPC / BASE_RPC / ARB_RPC env vars +// (e.g. when public endpoints rate-limit us). +const ( + defaultSolanaRPC = "https://api.mainnet-beta.solana.com" + defaultBaseRPC = "https://mainnet.base.org" + defaultArbitrumRPC = "https://arb1.arbitrum.io/rpc" +) + +func rpcURL(envKey, fallback string) string { + if v := strings.TrimSpace(os.Getenv(envKey)); v != "" { + return v + } + return fallback +} + +// NewTxExecutor creates a new transaction executor +func NewTxExecutor(solPrivKey, evmPrivKey, mobulaAPIKey string, dryRun bool) (*TxExecutor, error) { + tx := &TxExecutor{ + httpClient: &http.Client{Timeout: 30 * time.Second}, + mobulaAPIKey: mobulaAPIKey, + dryRun: dryRun, + } + + // Parse Solana private key (base58) + if solPrivKey != "" { + privKey, err := solana.PrivateKeyFromBase58(solPrivKey) + if err != nil { + return nil, fmt.Errorf("invalid solana private key: %w", err) + } + tx.solanaPrivateKey = privKey + log.Printf("✅ Solana wallet: %s", privKey.PublicKey().String()) + } + + // Parse EVM private key (hex) + if evmPrivKey != "" { + privKey, err := crypto.HexToECDSA(strings.TrimPrefix(evmPrivKey, "0x")) + if err != nil { + return nil, fmt.Errorf("invalid EVM private key: %w", err) + } + tx.evmPrivateKey = privKey + tx.evmAddress = crypto.PubkeyToAddress(privKey.PublicKey) + log.Printf("✅ EVM wallet: %s", tx.evmAddress.Hex()) + } + + // Connect to RPCs (only if not dry-run) + if !dryRun { + var err error + + solURL := rpcURL("SOLANA_RPC", defaultSolanaRPC) + baseURL := rpcURL("BASE_RPC", defaultBaseRPC) + arbURL := rpcURL("ARB_RPC", defaultArbitrumRPC) + + tx.solanaClient = rpc.New(solURL) + + tx.baseClient, err = ethclient.Dial(baseURL) + if err != nil { + return nil, fmt.Errorf("failed to connect to Base RPC: %w", err) + } + + tx.arbitrumClient, err = ethclient.Dial(arbURL) + if err != nil { + return nil, fmt.Errorf("failed to connect to Arbitrum RPC: %w", err) + } + + log.Printf("✅ Connected to Solana (%s), Base (%s), Arbitrum (%s)", solURL, baseURL, arbURL) + } + + return tx, nil +} + +// CanExecute returns true if we have the keys to execute +func (tx *TxExecutor) CanExecute() bool { + return tx.solanaPrivateKey != nil && tx.evmPrivateKey != nil && !tx.dryRun +} + +// ExecuteSolanaTransaction signs and broadcasts a Solana transaction +func (tx *TxExecutor) ExecuteSolanaTransaction(serializedTxBase64 string) (string, error) { + if tx.dryRun { + return "dry-run-solana-tx", nil + } + + ctx := context.Background() + + // Decode base64 transaction + txBytes, err := base64.StdEncoding.DecodeString(serializedTxBase64) + if err != nil { + return "", fmt.Errorf("failed to decode tx: %w", err) + } + + // Parse transaction + transaction, err := solana.TransactionFromBytes(txBytes) + if err != nil { + return "", fmt.Errorf("failed to parse tx: %w", err) + } + + // Get fresh blockhash (the one from API might be expired) + recentBlockhash, err := tx.solanaClient.GetLatestBlockhash(ctx, rpc.CommitmentFinalized) + if err != nil { + return "", fmt.Errorf("failed to get blockhash: %w", err) + } + transaction.Message.RecentBlockhash = recentBlockhash.Value.Blockhash + + // Sign transaction with fresh blockhash + _, err = transaction.Sign(func(key solana.PublicKey) *solana.PrivateKey { + if key.Equals(tx.solanaPrivateKey.PublicKey()) { + return &tx.solanaPrivateKey + } + return nil + }) + if err != nil { + return "", fmt.Errorf("failed to sign tx: %w", err) + } + + // Send transaction + sig, err := tx.solanaClient.SendTransaction(ctx, transaction) + if err != nil { + return "", fmt.Errorf("failed to send tx: %w", err) + } + + log.Printf("📤 Solana TX sent: %s", sig.String()) + return sig.String(), nil +} + +// ExecuteSolanaFromInstructions builds and broadcasts a Solana TX from Relay instructions +func (tx *TxExecutor) ExecuteSolanaFromInstructions(instructions []RelaySolanaInstruction, lookupTables []string) (string, error) { + if tx.dryRun { + return "dry-run-solana-instructions-tx", nil + } + + ctx := context.Background() + + // Build instructions + var solInstructions []solana.Instruction + for _, inst := range instructions { + // Parse program ID + programID, err := solana.PublicKeyFromBase58(inst.ProgramId) + if err != nil { + return "", fmt.Errorf("invalid program ID %s: %w", inst.ProgramId, err) + } + + // Parse keys + var accounts []*solana.AccountMeta + for _, key := range inst.Keys { + pubkey, err := solana.PublicKeyFromBase58(key.Pubkey) + if err != nil { + return "", fmt.Errorf("invalid pubkey %s: %w", key.Pubkey, err) + } + accounts = append(accounts, &solana.AccountMeta{ + PublicKey: pubkey, + IsSigner: key.IsSigner, + IsWritable: key.IsWritable, + }) + } + + // Parse instruction data (hex-encoded) + data, err := hex.DecodeString(strings.TrimPrefix(inst.Data, "0x")) + if err != nil { + return "", fmt.Errorf("invalid instruction data: %w", err) + } + + solInstructions = append(solInstructions, solana.NewInstruction(programID, accounts, data)) + } + + // Get fresh blockhash + recentBlockhash, err := tx.solanaClient.GetLatestBlockhash(ctx, rpc.CommitmentFinalized) + if err != nil { + return "", fmt.Errorf("failed to get blockhash: %w", err) + } + + // Build transaction + transaction, err := solana.NewTransaction( + solInstructions, + recentBlockhash.Value.Blockhash, + solana.TransactionPayer(tx.solanaPrivateKey.PublicKey()), + ) + if err != nil { + return "", fmt.Errorf("failed to create tx: %w", err) + } + + // Sign transaction + _, err = transaction.Sign(func(key solana.PublicKey) *solana.PrivateKey { + if key.Equals(tx.solanaPrivateKey.PublicKey()) { + return &tx.solanaPrivateKey + } + return nil + }) + if err != nil { + return "", fmt.Errorf("failed to sign tx: %w", err) + } + + // Send transaction + sig, err := tx.solanaClient.SendTransaction(ctx, transaction) + if err != nil { + return "", fmt.Errorf("failed to send tx: %w", err) + } + + log.Printf("📤 Solana TX (from instructions) sent: %s", sig.String()) + return sig.String(), nil +} + +// ExecuteEVMTransaction signs and broadcasts an EVM transaction +func (tx *TxExecutor) ExecuteEVMTransaction(chain string, to string, data string, value string) (string, error) { + if tx.dryRun { + return "dry-run-evm-tx", nil + } + + // Select client based on chain + var client *ethclient.Client + var chainID *big.Int + switch strings.ToLower(chain) { + case "base": + client = tx.baseClient + chainID = big.NewInt(8453) + case "arbitrum": + client = tx.arbitrumClient + chainID = big.NewInt(42161) + default: + return "", fmt.Errorf("unsupported chain: %s", chain) + } + + ctx := context.Background() + + // Get nonce + nonce, err := client.PendingNonceAt(ctx, tx.evmAddress) + if err != nil { + return "", fmt.Errorf("failed to get nonce: %w", err) + } + + // Get gas price with 50% buffer to handle rapid base fee increases (esp. Arbitrum) + gasPrice, err := client.SuggestGasPrice(ctx) + if err != nil { + return "", fmt.Errorf("failed to get gas price: %w", err) + } + // Bump gas price by 50% to avoid "max fee per gas less than block base fee" errors + gasPrice = new(big.Int).Mul(gasPrice, big.NewInt(150)) + gasPrice = new(big.Int).Div(gasPrice, big.NewInt(100)) + + // Parse value. Mobula returns decimal (e.g. "17000000000000002" wei = 0.017 ETH); + // Relay/LiFi return hex ("0x..."). Auto-detect by 0x prefix — the previous + // always-hex code interpreted "17000000000000002" as 0x17000000000000002 = 26.5 + // ETH, busting ETH-native bridges with "insufficient funds for gas * price + value". + valueBig := big.NewInt(0) + if value != "" && value != "0" && value != "0x0" { + if strings.HasPrefix(value, "0x") { + valueBig.SetString(strings.TrimPrefix(value, "0x"), 16) + } else { + valueBig.SetString(value, 10) + } + } + + // Parse data + dataBytes := common.FromHex(data) + + // Parse to address + toAddr := common.HexToAddress(to) + + // Estimate gas + gasLimit := uint64(500000) // Default, should be estimated + + // Create transaction + evmTx := types.NewTransaction(nonce, toAddr, valueBig, gasLimit, gasPrice, dataBytes) + + // Sign transaction + signer := types.NewEIP155Signer(chainID) + signedTx, err := types.SignTx(evmTx, signer, tx.evmPrivateKey) + if err != nil { + return "", fmt.Errorf("failed to sign tx: %w", err) + } + + // Send transaction + err = client.SendTransaction(ctx, signedTx) + if err != nil { + return "", fmt.Errorf("failed to send tx: %w", err) + } + + txHash := signedTx.Hash().Hex() + log.Printf("📤 %s TX sent: %s", chain, txHash) + return txHash, nil +} + +// ApproveERC20 sends an ERC20 approve transaction +func (tx *TxExecutor) ApproveERC20(chain, tokenAddress, spender, amount string) (string, error) { + if tx.dryRun { + return "dry-run-approval-tx", nil + } + + // ERC20 approve(address spender, uint256 amount) selector: 0x095ea7b3 + spenderAddr := common.HexToAddress(spender) + + // Parse amount + amountBig := new(big.Int) + amountBig.SetString(amount, 10) + + // Build calldata: function selector + spender (32 bytes) + amount (32 bytes) + data := make([]byte, 68) + copy(data[0:4], []byte{0x09, 0x5e, 0xa7, 0xb3}) // approve selector + copy(data[4:36], common.LeftPadBytes(spenderAddr.Bytes(), 32)) + copy(data[36:68], common.LeftPadBytes(amountBig.Bytes(), 32)) + + return tx.ExecuteEVMTransaction(chain, tokenAddress, "0x"+hex.EncodeToString(data), "0") +} + +// CheckEVMTxStatus checks if an EVM transaction succeeded or failed +func (tx *TxExecutor) CheckEVMTxStatus(chain string, txHash string) (bool, error) { + var client *ethclient.Client + switch strings.ToLower(chain) { + case "base": + client = tx.baseClient + case "arbitrum": + client = tx.arbitrumClient + default: + return false, fmt.Errorf("unsupported chain: %s", chain) + } + + ctx := context.Background() + receipt, err := client.TransactionReceipt(ctx, common.HexToHash(txHash)) + if err != nil { + return false, err // TX not mined yet or error + } + + // Status 1 = success, 0 = failure/revert + return receipt.Status == 1, nil +} + +// BridgeStatus represents the status of a bridge transaction +type BridgeStatus struct { + Status string // "pending", "filled", "refunded", "failed" + TxHash string + ToTxHash string + LatencyMs int64 +} + +// PollMobulaStatus polls Mobula bridge status until completion +func (tx *TxExecutor) PollMobulaStatus(txHash string, timeout time.Duration) (*BridgeStatus, error) { + if tx.dryRun { + return &BridgeStatus{Status: "filled", TxHash: txHash, LatencyMs: 5000}, nil + } + + deadline := time.Now().Add(timeout) + pollInterval := 5 * time.Second + + for time.Now().Before(deadline) { + status, err := tx.getMobulaStatus(txHash) + if err != nil { + log.Printf("⚠️ Mobula status poll error: %v", err) + time.Sleep(pollInterval) + continue + } + + // Terminal statuses: filled, settled (cross-chain complete), refunded, failed + if status.Status == "filled" || status.Status == "settled" || status.Status == "refunded" || status.Status == "failed" { + return status, nil + } + + log.Printf("⏳ Mobula status: %s (waiting...)", status.Status) + time.Sleep(pollInterval) + } + + return nil, fmt.Errorf("timeout waiting for bridge completion") +} + +func (tx *TxExecutor) getMobulaStatus(txHash string) (*BridgeStatus, error) { + url := fmt.Sprintf("https://api.mobula.io/api/2/bridge/status/%s", txHash) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + // Header only - no query param (per Mobula docs) + if tx.mobulaAPIKey == "" { + return nil, fmt.Errorf("mobula API key is empty") + } + req.Header.Set("Authorization", tx.mobulaAPIKey) + + resp, err := tx.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("status API error %d: %s", resp.StatusCode, string(body)) + } + + var result struct { + Data struct { + Status string `json:"status"` + LatencyMs int64 `json:"latencyMs"` + ToTxHash string `json:"toTxHash"` + } `json:"data"` + } + if err := json.Unmarshal(body, &result); err != nil { + return nil, err + } + + return &BridgeStatus{ + Status: result.Data.Status, + TxHash: txHash, + ToTxHash: result.Data.ToTxHash, + LatencyMs: result.Data.LatencyMs, + }, nil +} + +// PollLiFiStatus polls Li.Fi bridge status until completion +func (tx *TxExecutor) PollLiFiStatus(txHash, fromChain, toChain string, timeout time.Duration) (*BridgeStatus, error) { + if tx.dryRun { + return &BridgeStatus{Status: "filled", TxHash: txHash, LatencyMs: 5000}, nil + } + + deadline := time.Now().Add(timeout) + pollInterval := 5 * time.Second + + for time.Now().Before(deadline) { + status, err := tx.getLiFiStatus(txHash, fromChain, toChain) + if err != nil { + log.Printf("⚠️ Li.Fi status poll error: %v", err) + time.Sleep(pollInterval) + continue + } + + // Li.Fi uses: PENDING, DONE, FAILED + if status.Status == "DONE" { + status.Status = "filled" + return status, nil + } + if status.Status == "FAILED" { + status.Status = "failed" + return status, nil + } + + log.Printf("⏳ Li.Fi status: %s (waiting...)", status.Status) + time.Sleep(pollInterval) + } + + return nil, fmt.Errorf("timeout waiting for bridge completion") +} + +func (tx *TxExecutor) getLiFiStatus(txHash, fromChain, toChain string) (*BridgeStatus, error) { + url := fmt.Sprintf("https://li.quest/v1/status?txHash=%s&fromChain=%s&toChain=%s", + txHash, fromChain, toChain) + + resp, err := tx.httpClient.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("status API error %d: %s", resp.StatusCode, string(body)) + } + + var result struct { + Status string `json:"status"` + Sending struct{ TxHash string `json:"txHash"` } `json:"sending"` + Received struct{ TxHash string `json:"txHash"` } `json:"received"` + } + if err := json.Unmarshal(body, &result); err != nil { + return nil, err + } + + return &BridgeStatus{ + Status: result.Status, + TxHash: result.Sending.TxHash, + ToTxHash: result.Received.TxHash, + }, nil +} + +// PollRelayStatus polls Relay bridge status until completion +func (tx *TxExecutor) PollRelayStatus(requestID string, timeout time.Duration) (*BridgeStatus, error) { + if tx.dryRun { + return &BridgeStatus{Status: "filled", TxHash: requestID, LatencyMs: 5000}, nil + } + + deadline := time.Now().Add(timeout) + pollInterval := 5 * time.Second + + for time.Now().Before(deadline) { + status, err := tx.getRelayStatus(requestID) + if err != nil { + log.Printf("⚠️ Relay status poll error: %v", err) + time.Sleep(pollInterval) + continue + } + + // Relay uses: pending, success, refunded + if status.Status == "success" { + status.Status = "filled" + return status, nil + } + if status.Status == "refunded" || status.Status == "failed" { + return status, nil + } + + log.Printf("⏳ Relay status: %s (waiting...)", status.Status) + time.Sleep(pollInterval) + } + + return nil, fmt.Errorf("timeout waiting for bridge completion") +} + +func (tx *TxExecutor) getRelayStatus(requestID string) (*BridgeStatus, error) { + url := fmt.Sprintf("https://api.relay.link/intents/status/v3?requestId=%s", requestID) + + resp, err := tx.httpClient.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("status API error %d: %s", resp.StatusCode, string(body)) + } + + var result struct { + Status string `json:"status"` + TxHash string `json:"txHash"` + } + if err := json.Unmarshal(body, &result); err != nil { + return nil, err + } + + return &BridgeStatus{ + Status: result.Status, + TxHash: result.TxHash, + }, nil +} + +// Close closes all connections +func (tx *TxExecutor) Close() { + if tx.baseClient != nil { + tx.baseClient.Close() + } + if tx.arbitrumClient != nil { + tx.arbitrumClient.Close() + } +} diff --git a/harnesses/bridge-monitor/cmd/monitor/wallet.go b/harnesses/bridge-monitor/cmd/monitor/wallet.go new file mode 100644 index 00000000..f25c6397 --- /dev/null +++ b/harnesses/bridge-monitor/cmd/monitor/wallet.go @@ -0,0 +1,90 @@ +package main + +import ( + "fmt" + "log" +) + +// WalletManager handles wallet operations for both EVM and Solana +type WalletManager struct { + // EVM + EVMPrivateKey string + EVMAddress string + + // Solana + SolanaPrivateKey string + SolanaAddress string + + // Mode + DryRun bool +} + +// NewWalletManager creates a new wallet manager from config +func NewWalletManager(config *Config) (*WalletManager, error) { + wm := &WalletManager{ + EVMPrivateKey: config.WalletEVMPrivateKey, + EVMAddress: config.WalletEVMAddress, + SolanaPrivateKey: config.WalletSOLPrivateKey, + SolanaAddress: config.WalletSOLAddress, + DryRun: config.ExecutionMode != "production" && config.ExecutionMode != "single-test", + } + + // Validate + if wm.EVMAddress == "" && wm.SolanaAddress == "" { + // Use default test addresses for quote-only mode + wm.EVMAddress = "0x867A784039D4842A32Ddd1277729Ad1373301458" + wm.SolanaAddress = "DYw8jCTfwHNRJhhmFcbXvVDTqWMEVFBX6ZKUmG5CNSKK" + log.Println("⚠️ No wallet configured, using default addresses (quote-only mode)") + } + + return wm, nil +} + +// HasPrivateKeys returns true if we have private keys configured +func (wm *WalletManager) HasPrivateKeys() bool { + return wm.EVMPrivateKey != "" && wm.SolanaPrivateKey != "" +} + +// CanExecute returns true if we can execute real transactions +func (wm *WalletManager) CanExecute() bool { + return wm.HasPrivateKeys() && !wm.DryRun +} + +// GetEVMSigner returns an EVM signer (placeholder - would use go-ethereum) +func (wm *WalletManager) GetEVMSigner() (interface{}, error) { + if wm.EVMPrivateKey == "" { + return nil, fmt.Errorf("EVM private key not configured") + } + + // TODO: Implement with go-ethereum + // privateKey, err := crypto.HexToECDSA(strings.TrimPrefix(wm.EVMPrivateKey, "0x")) + // if err != nil { + // return nil, err + // } + // return bind.NewKeyedTransactorWithChainID(privateKey, chainID) + + return nil, fmt.Errorf("EVM signing not yet implemented") +} + +// GetSolanaSigner returns a Solana signer (placeholder) +func (wm *WalletManager) GetSolanaSigner() (interface{}, error) { + if wm.SolanaPrivateKey == "" { + return nil, fmt.Errorf("Solana private key not configured") + } + + // TODO: Implement with solana-go + // privateKey := solana.MustPrivateKeyFromBase58(wm.SolanaPrivateKey) + // return privateKey, nil + + return nil, fmt.Errorf("Solana signing not yet implemented") +} + +// LogWalletInfo logs wallet addresses (not keys!) +func (wm *WalletManager) LogWalletInfo() { + log.Println("🔑 Wallet Configuration:") + log.Printf(" EVM Address: %s", wm.EVMAddress) + log.Printf(" Solana Address: %s", wm.SolanaAddress) + log.Printf(" Has Private Keys: %v", wm.HasPrivateKeys()) + log.Printf(" Can Execute: %v", wm.CanExecute()) + log.Printf(" Mode: %s", map[bool]string{true: "dry-run", false: "production"}[wm.DryRun]) +} diff --git a/harnesses/bridge-monitor/cmd/monitor/wallet_snapshot.go b/harnesses/bridge-monitor/cmd/monitor/wallet_snapshot.go new file mode 100644 index 00000000..98688929 --- /dev/null +++ b/harnesses/bridge-monitor/cmd/monitor/wallet_snapshot.go @@ -0,0 +1,347 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "os" + "sort" + "strings" + "time" +) + +// WalletSnapshot captures wallet USD balances at a point in time. Used by the daily +// P&L cron to compute deltas — how much fees we've burned in the last 24h, per chain. +type WalletSnapshot struct { + Timestamp time.Time `json:"timestamp"` + Balances map[string]map[string]float64 `json:"balances"` // chain → token → USD + TotalUSD float64 `json:"total_usd"` +} + +const snapshotPath = "/tmp/bridge_wallet_snapshot.json" + +// captureSnapshot fetches live balances, strips contract-address keys (we want symbols +// for human-readable diffs), sums total, and returns it. +func captureSnapshot(bc *BalanceChecker) (*WalletSnapshot, error) { + raw, err := bc.GetAllBalances() + if err != nil { + return nil, err + } + + snap := &WalletSnapshot{ + Timestamp: time.Now().UTC(), + Balances: make(map[string]map[string]float64), + } + for chain, tokens := range raw { + snap.Balances[chain] = make(map[string]float64) + for token, usd := range tokens { + if strings.HasPrefix(token, "0x") || len(token) > 12 { + continue + } + if usd < 0.01 { + continue + } + snap.Balances[chain][token] = usd + snap.TotalUSD += usd + } + } + return snap, nil +} + +func loadSnapshot() (*WalletSnapshot, error) { + b, err := os.ReadFile(snapshotPath) + if err != nil { + return nil, err + } + var s WalletSnapshot + if err := json.Unmarshal(b, &s); err != nil { + return nil, err + } + return &s, nil +} + +func saveSnapshot(s *WalletSnapshot) error { + b, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + return os.WriteFile(snapshotPath, b, 0644) +} + +// StartDailyPnLCron fires daily at 09:30 UTC (30 min before scheduled executions) +// and posts a Slack P&L report vs ~24h ago. Safe against redeploys: if the stored +// snapshot is missing or older than 28h we just re-snapshot silently. +func StartDailyPnLCron(bc *BalanceChecker, slack *SlackNotifier) { + if bc == nil { + log.Println("⚠️ Daily P&L cron disabled: no balance checker") + return + } + + // Seed snapshot on startup if none exists yet. + if _, err := loadSnapshot(); err != nil { + if snap, err := captureSnapshot(bc); err == nil { + _ = saveSnapshot(snap) + log.Printf("📸 Initial wallet snapshot: $%.2f", snap.TotalUSD) + } else { + log.Printf("⚠️ Initial snapshot failed: %v", err) + } + } + + go func() { + for { + // Sleep until next 09:30 UTC. + now := time.Now().UTC() + next := time.Date(now.Year(), now.Month(), now.Day(), 9, 30, 0, 0, time.UTC) + if !next.After(now) { + next = next.AddDate(0, 0, 1) + } + wait := next.Sub(now) + log.Printf("📅 Daily P&L cron scheduled for %s (in %v)", next.Format("2006-01-02 15:04 UTC"), wait.Round(time.Minute)) + time.Sleep(wait) + + runDailyPnL(bc, slack) + } + }() +} + +func runDailyPnL(bc *BalanceChecker, slack *SlackNotifier) { + current, err := captureSnapshot(bc) + if err != nil { + log.Printf("⚠️ P&L cron capture failed: %v", err) + return + } + + prev, err := loadSnapshot() + if err != nil || time.Since(prev.Timestamp) > 28*time.Hour { + // First run after fresh deploy, or stale snapshot — just store and report absolute. + _ = saveSnapshot(current) + if slack != nil { + msg := fmt.Sprintf("📸 *Wallet Snapshot (no prior data)*\n\n*Total:* $%.2f\n%s", + current.TotalUSD, formatSnapshotBalances(current)) + _ = slack.send(msg) + } + return + } + + // Compute diffs per (chain, token), sort by impact for readability. + type diff struct { + Chain string + Token string + Before float64 + After float64 + Delta float64 + Percent float64 + } + var diffs []diff + + seen := make(map[string]bool) + for chain, tokens := range current.Balances { + for token, after := range tokens { + key := chain + "/" + token + seen[key] = true + before := prev.Balances[chain][token] + d := after - before + pct := 0.0 + if before > 0.01 { + pct = (d / before) * 100 + } + diffs = append(diffs, diff{chain, token, before, after, d, pct}) + } + } + // Assets that disappeared entirely (fully drained) — show them too. + for chain, tokens := range prev.Balances { + for token, before := range tokens { + if seen[chain+"/"+token] { + continue + } + diffs = append(diffs, diff{chain, token, before, 0, -before, -100}) + } + } + sort.Slice(diffs, func(i, j int) bool { return diffs[i].Delta < diffs[j].Delta }) + + totalDelta := current.TotalUSD - prev.TotalUSD + totalPct := 0.0 + if prev.TotalUSD > 0.01 { + totalPct = (totalDelta / prev.TotalUSD) * 100 + } + + emoji := "📊" + if totalDelta < -1 { + emoji = "📉" + } else if totalDelta > 1 { + emoji = "📈" + } + + // Show every token (even unchanged) so the breakdown total reconciles to + // the headline number — too many readers were confused by missing rows when + // a token's price was stable. + var body strings.Builder + for _, d := range diffs { + if d.Delta > -0.01 && d.Delta < 0.01 { + body.WriteString(fmt.Sprintf("• %s/%s: $%.2f (=)\n", d.Chain, d.Token, d.After)) + continue + } + sign := "+" + if d.Delta < 0 { + sign = "" + } + body.WriteString(fmt.Sprintf("• %s/%s: $%.2f → $%.2f (%s$%.2f / %s%.2f%%)\n", + d.Chain, d.Token, d.Before, d.After, sign, d.Delta, sign, d.Percent)) + } + + hours := time.Since(prev.Timestamp).Hours() + health := formatTierHealth(current.Balances) + msg := fmt.Sprintf(`%s *Daily Wallet P&L* (last %.0fh) + +*Total:* $%.2f → $%.2f (%+.2f USD / %+.2f%%) + +*Breakdown:* +%s +%s`, + emoji, hours, prev.TotalUSD, current.TotalUSD, totalDelta, totalPct, body.String(), health, + ) + + if slack != nil { + if err := slack.send(msg); err != nil { + log.Printf("⚠️ P&L Slack send failed: %v", err) + } + } + log.Printf("📊 Daily P&L: $%.2f → $%.2f (%+.2f USD)", prev.TotalUSD, current.TotalUSD, totalDelta) + + _ = saveSnapshot(current) +} + +// formatTierHealth produces the "can each tier run?" section of the daily report. +// +// Reality of a scheduled cycle (main.go): routes run sequentially R1 → R2 → R3, each +// RunReal blocks until its 3 bridges fully settle on the destination chain. So R2 +// starts AFTER R1 has delivered ~3×amount × 99% to Base, and R3 starts AFTER R2 has +// delivered ~3×amount × 99% to Arb. The per-leg requirement is therefore: +// +// R1 source (Sol USDC): initial ≥ 3 × amount (no preceding inflow) +// R2 source (Base USDC): initial + R1_inflow ≥ 3×amount +// R3 source (Arb USDT): initial + R2_inflow ≥ 3×amount +// +// where inflow ≈ 3×amount × (1 - avg_fee). Using a conservative 2% cumulative loss. +func formatTierHealth(balances map[string]map[string]float64) string { + const netFactor = 0.98 // 1 - 2% cumulative fees per 3-bridge hop + + solUSDC := balances["Solana"]["USDC"] + baseUSDC := balances["Base"]["USDC"] + arbUSDT := balances["Arbitrum"]["USDT0"] + tiers := []float64{5, 50, 300} + + // Simulate the cycle for a given tier using the sequential per-bridge model + // (1× tier per leg, matches cycle_sim.SimulateTriangleCycle). Shared logic + // kept inline for the dashboard-style grid output. + type result struct { + r1OK, r2OK, r3OK bool + blockLeg string + blockNeed float64 + blockHave float64 + } + simulate := func(t float64) result { + need := t // sequential per-bridge: 1× tier per leg + r := result{} + + if solUSDC < need { + return result{blockLeg: "R1 Sol USDC", blockNeed: need, blockHave: solUSDC} + } + r.r1OK = true + baseEffective := baseUSDC + need*netFactor // R1 inflow + + if baseEffective < need { + return result{r1OK: true, blockLeg: "R2 Base USDC", blockNeed: need, blockHave: baseEffective} + } + r.r2OK = true + arbEffective := arbUSDT + need*netFactor // R2 inflow + + if arbEffective < need { + return result{r1OK: true, r2OK: true, blockLeg: "R3 Arb USDT", blockNeed: need, blockHave: arbEffective} + } + r.r3OK = true + return r + } + + // Grid. + var grid strings.Builder + grid.WriteString("\n🩺 *Tier Health* (sequential per-bridge · 1× tier per leg)\n```\n") + grid.WriteString(" $5 $50 $300\n") + rows := []struct { + label string + get func(result) bool + }{ + {"R1 Sol USDC ", func(r result) bool { return r.r1OK }}, + {"R2 Base USDC", func(r result) bool { return r.r2OK }}, + {"R3 Arb USDT ", func(r result) bool { return r.r3OK }}, + } + results := make(map[float64]result) + for _, t := range tiers { + results[t] = simulate(t) + } + for _, row := range rows { + grid.WriteString(row.label) + for _, t := range tiers { + status := "❌" + if row.get(results[t]) { + status = "✅" + } + grid.WriteString(fmt.Sprintf(" %s ", status)) + } + grid.WriteString("\n") + } + grid.WriteString("```\n") + + // Per-tier actionable message for blocked tiers. + tierEmoji := map[float64]string{5: "⚡", 50: "💰", 300: "💎"} + tierLabel := map[float64]string{5: "daily", 50: "Mon+Thu", 300: "Mon weekly"} + + for _, t := range tiers { + r := results[t] + if r.r1OK && r.r2OK && r.r3OK { + continue + } + gap := r.blockNeed - r.blockHave + grid.WriteString(fmt.Sprintf("\n%s *$%.0f tier (%s)* — blocks on %s\n", + tierEmoji[t], t, tierLabel[t], r.blockLeg)) + grid.WriteString(fmt.Sprintf(" need $%.2f, have $%.2f → *bridge +$%.2f minimum*\n", r.blockNeed, r.blockHave, gap)) + + // Can we cover it from other legs? (for R1 Sol blocker only — R2/R3 fed by upstream) + if strings.HasPrefix(r.blockLeg, "R1") { + totalStable := solUSDC + baseUSDC + arbUSDT + if totalStable < r.blockNeed { + grid.WriteString(fmt.Sprintf(" ⚠️ total stable pool $%.2f < $%.2f required — tier not viable without external top-up of $%.0f\n", + totalStable, r.blockNeed, r.blockNeed-totalStable)) + } else { + grid.WriteString(fmt.Sprintf(" → pull from Base USDC ($%.0f) or Arb USDT ($%.0f) to Sol USDC\n", baseUSDC, arbUSDT)) + } + } + } + + // Gas sanity check (each cycle = ~3 outgoing TX per EVM chain). + var gasWarn strings.Builder + checkGas := func(chain, token string, min float64) { + if v := balances[chain][token]; v > 0 && v < min { + gasWarn.WriteString(fmt.Sprintf(" • %s %s: $%.2f (< $%.2f) — top up native\n", chain, token, v, min)) + } + } + checkGas("Solana", "SOL", 1.0) + checkGas("Base", "ETH", 3.0) + checkGas("Arbitrum", "ETH", 3.0) + if gasWarn.Len() > 0 { + grid.WriteString("\n⛽ *Gas warning*:\n") + grid.WriteString(gasWarn.String()) + } + + return grid.String() +} + +func formatSnapshotBalances(s *WalletSnapshot) string { + var b strings.Builder + for chain, tokens := range s.Balances { + for token, usd := range tokens { + b.WriteString(fmt.Sprintf("• %s/%s: $%.2f\n", chain, token, usd)) + } + } + return b.String() +} diff --git a/harnesses/bridge-monitor/deploy/DEPLOY.md b/harnesses/bridge-monitor/deploy/DEPLOY.md new file mode 100644 index 00000000..cac1b543 --- /dev/null +++ b/harnesses/bridge-monitor/deploy/DEPLOY.md @@ -0,0 +1,158 @@ +# Railway Deployment Guide + +## Architecture + +4 Railway services from the GitHub repo: + +``` +harnesses/bridge-monitor/ +├── Dockerfile → Service "bridge-monitor" +└── deploy/ + ├── prometheus/Dockerfile → Service "prometheus" + ├── grafana/Dockerfile → Service "grafana" + └── alertmanager/Dockerfile → Service "alertmanager" +``` + +--- + +## Deployment Steps + +### 1. Service: bridge-monitor + +**Railway Config:** +- **Root Directory**: `harnesses/bridge-monitor` +- **Dockerfile Path**: `./Dockerfile` +- **Port**: 9090 + +**Environment Variables:** +```env +MOBULA_API_KEY=your_key +WALLET_EVM_ADDRESS=0x... +WALLET_SOL_ADDRESS=... +EXECUTION_MODE=dry-run +MONITOR_REGION=railway-prod +``` + +**Service Name:** `bridge-monitor` + +--- + +### 2. Service: prometheus + +**Railway Config:** +- **Root Directory**: `harnesses/bridge-monitor/deploy/prometheus` +- **Dockerfile Path**: `./Dockerfile` +- **Port**: 9090 + +**Service Name:** `prometheus` + +**Networking:** Accessible via `prometheus.railway.internal:9090` + +--- + +### 3. Service: grafana (Public Dashboard) + +**Railway Config:** +- **Root Directory**: `harnesses/bridge-monitor/deploy/grafana` +- **Dockerfile Path**: `./Dockerfile` +- **Port**: 3000 + +**Service Name:** `grafana` + +**Networking:** +- ✅ **Generate public domain** (publicly accessible dashboard) +- Internal: `grafana.railway.internal:3000` + +--- + +### 4. Service: alertmanager + +**Railway Config:** +- **Root Directory**: `harnesses/bridge-monitor/deploy/alertmanager` +- **Dockerfile Path**: `./Dockerfile` +- **Port**: 9093 + +**Service Name:** `alertmanager` + +**Networking:** `alertmanager.railway.internal:9093` + +--- + +## Recommended Order + +1. **prometheus** (no dependencies) +2. **alertmanager** (no dependencies) +3. **bridge-monitor** (scrapes to prometheus) +4. **grafana** (datasource to prometheus) + +--- + +## Verification + +### 1. Bridge Monitor +```bash +curl https://.railway.app/metrics +``` + +### 2. Prometheus +- Go to **Status → Targets** +- Verify `bridge-monitor` is **UP** + +### 3. Grafana (Public Dashboard) +- Dashboard displays without login (anonymous auth) +- Graphs show data after 2-3 minutes + +--- + +## Local Development + +Run the full stack locally: +```bash +cd deploy +docker-compose up -d +``` + +Access: +- **Prometheus**: http://localhost:9091 +- **Grafana**: http://localhost:3000 +- **Monitor Metrics**: http://localhost:9090/metrics +- **Alertmanager**: http://localhost:9093 + +--- + +## Key Points + +✅ **Service Names** must be exactly: `bridge-monitor`, `prometheus`, `grafana`, `alertmanager` +✅ Railway internal URLs: `.railway.internal` +✅ Grafana has **anonymous auth** = public access without login +✅ Prometheus scrapes bridge-monitor every 15s +✅ Quote tests run every **5 minutes** +✅ Execution tests run at fixed UTC times (10:00 UTC) + +--- + +## Estimated Cost + +- **Railway Hobby:** $5/service × 4 = **$20/month** +- **Execution costs:** ~$100/month (bridge fees for real transactions) + +--- + +## Troubleshooting + +### Prometheus not scraping bridge-monitor +```bash +railway logs prometheus +``` +Verify `bridge-monitor.railway.internal:9090` is accessible. + +### Grafana shows "No Data" +- Wait 2-3 minutes for data collection +- Check datasource: Settings → Data Sources → Prometheus +- URL should be: `http://prometheus.railway.internal:9090` +- Test query: `up` + +### Service crashes on startup +- Check logs: `railway logs ` +- Verify port matches (9090, 3000, 9093) +- Verify Dockerfile path is correct diff --git a/harnesses/bridge-monitor/deploy/alertmanager/Dockerfile b/harnesses/bridge-monitor/deploy/alertmanager/Dockerfile new file mode 100644 index 00000000..b35cdf77 --- /dev/null +++ b/harnesses/bridge-monitor/deploy/alertmanager/Dockerfile @@ -0,0 +1,15 @@ +FROM prom/alertmanager:latest + +USER root + +# Newer prom/alertmanager images are busybox-based (no apk). We use plain sed in +# entrypoint.sh to substitute SLACK_WEBHOOK_URL — no envsubst dependency needed. + +COPY alertmanager.yml.tmpl /etc/alertmanager/alertmanager.yml.tmpl +COPY templates.tmpl /etc/alertmanager/templates.tmpl +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 9093 + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/harnesses/bridge-monitor/deploy/alertmanager/alertmanager.yml.tmpl b/harnesses/bridge-monitor/deploy/alertmanager/alertmanager.yml.tmpl new file mode 100644 index 00000000..f7d4eeea --- /dev/null +++ b/harnesses/bridge-monitor/deploy/alertmanager/alertmanager.yml.tmpl @@ -0,0 +1,41 @@ +global: + resolve_timeout: 5m + slack_api_url: '${SLACK_WEBHOOK_URL}' + +templates: + - '/etc/alertmanager/templates.tmpl' + +route: + receiver: 'slack-warnings' + group_by: ['alertname', 'chain'] + group_wait: 30s + group_interval: 5m + repeat_interval: 6h + routes: + - matchers: + - severity = "critical" + receiver: 'slack-criticals' + group_wait: 10s + repeat_interval: 1h + +receivers: + - name: 'slack-warnings' + slack_configs: + - channel: '#bridge-bench' + send_resolved: true + title: '{{ template "slack.title" . }}' + text: '{{ template "slack.text" . }}' + + - name: 'slack-criticals' + slack_configs: + - channel: '#bridge-bench' + send_resolved: true + title: ':rotating_light: CRITICAL — {{ template "slack.title" . }}' + text: '{{ template "slack.text" . }}' + +inhibit_rules: + - source_matchers: + - severity = "critical" + target_matchers: + - severity = "warning" + equal: ['alertname', 'chain'] diff --git a/harnesses/bridge-monitor/deploy/alertmanager/entrypoint.sh b/harnesses/bridge-monitor/deploy/alertmanager/entrypoint.sh new file mode 100755 index 00000000..73ca47ff --- /dev/null +++ b/harnesses/bridge-monitor/deploy/alertmanager/entrypoint.sh @@ -0,0 +1,19 @@ +#!/bin/sh +set -e + +# Alertmanager does not expand env vars in its YAML by itself. Substitute the +# SLACK_WEBHOOK_URL placeholder at container start using sed (busybox-safe — no +# envsubst dependency). The Slack webhook URL contains only /, alphanumerics and +# colons so | as the sed delimiter is safe. +if [ -z "$SLACK_WEBHOOK_URL" ]; then + echo "⚠️ SLACK_WEBHOOK_URL not set — alerts will be dropped by Slack receiver." +fi + +sed "s|\${SLACK_WEBHOOK_URL}|${SLACK_WEBHOOK_URL}|g" \ + /etc/alertmanager/alertmanager.yml.tmpl \ + > /etc/alertmanager/alertmanager.yml + +exec /bin/alertmanager \ + --config.file=/etc/alertmanager/alertmanager.yml \ + --storage.path=/alertmanager \ + --log.level=info diff --git a/harnesses/bridge-monitor/deploy/alertmanager/templates.tmpl b/harnesses/bridge-monitor/deploy/alertmanager/templates.tmpl new file mode 100644 index 00000000..c4a344ed --- /dev/null +++ b/harnesses/bridge-monitor/deploy/alertmanager/templates.tmpl @@ -0,0 +1,19 @@ +{{ define "slack.title" }} +{{- if eq .Status "firing" -}} + [{{ .Status | toUpper }}] {{ .CommonLabels.alertname }} +{{- else -}} + [RESOLVED] {{ .CommonLabels.alertname }} +{{- end -}} +{{ end }} + +{{ define "slack.text" }} +{{ range .Alerts -}} +*{{ .Annotations.summary }}* +{{ .Annotations.description }} +{{ if .Labels.bridge }}• bridge: `{{ .Labels.bridge }}`{{ end }} +{{ if .Labels.from_chain }}• route: `{{ .Labels.from_chain }} → {{ .Labels.to_chain }}`{{ end }} +{{ if .Labels.chain }}• chain: `{{ .Labels.chain }}`{{ end }} +• severity: `{{ .Labels.severity }}` +• started: {{ .StartsAt.Format "2006-01-02 15:04:05 UTC" }} +{{ end }} +{{ end }} diff --git a/harnesses/bridge-monitor/deploy/docker-compose.yml b/harnesses/bridge-monitor/deploy/docker-compose.yml new file mode 100644 index 00000000..d9e8fb95 --- /dev/null +++ b/harnesses/bridge-monitor/deploy/docker-compose.yml @@ -0,0 +1,80 @@ +version: '3.8' + +services: + monitor: + build: + context: .. + dockerfile: Dockerfile + container_name: bridge-monitor + restart: unless-stopped + env_file: + - ../.env + ports: + - "9090:9090" + networks: + - monitoring + + prometheus: + image: prom/prometheus:latest + container_name: bridge-prometheus + restart: unless-stopped + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + - '--storage.tsdb.retention.time=365d' + - '--web.enable-lifecycle' + - '--web.enable-admin-api' + volumes: + - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml + - ./prometheus/alert_rules.yml:/etc/prometheus/alert_rules.yml + - prometheus_data:/prometheus + ports: + - "9091:9090" + networks: + - monitoring + + alertmanager: + build: + context: ./alertmanager + dockerfile: Dockerfile + container_name: bridge-alertmanager + restart: unless-stopped + environment: + - SLACK_WEBHOOK_URL=${SLACK_WEBHOOK_URL} + volumes: + - alertmanager_data:/alertmanager + ports: + - "9093:9093" + networks: + - monitoring + + grafana: + image: grafana/grafana:latest + container_name: bridge-grafana + restart: unless-stopped + environment: + - GF_SECURITY_ADMIN_PASSWORD=${GF_SECURITY_ADMIN_PASSWORD:-admin} + - GF_USERS_ALLOW_SIGN_UP=false + - GF_AUTH_ANONYMOUS_ENABLED=true + - GF_AUTH_ANONYMOUS_ORG_ROLE=Viewer + - GF_AUTH_BASIC_ENABLED=false + - GF_AUTH_DISABLE_LOGIN_FORM=true + volumes: + - ./grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards + - ./grafana/provisioning/datasources:/etc/grafana/provisioning/datasources + - grafana_data:/var/lib/grafana + ports: + - "3000:3000" + depends_on: + - prometheus + networks: + - monitoring + +networks: + monitoring: + driver: bridge + +volumes: + prometheus_data: + alertmanager_data: + grafana_data: diff --git a/harnesses/bridge-monitor/deploy/grafana/Dockerfile b/harnesses/bridge-monitor/deploy/grafana/Dockerfile new file mode 100644 index 00000000..e8638901 --- /dev/null +++ b/harnesses/bridge-monitor/deploy/grafana/Dockerfile @@ -0,0 +1,20 @@ +FROM grafana/grafana:latest + +USER root + +# Copy provisioning configs (we're in grafana folder) +COPY provisioning /etc/grafana/provisioning + +# Set permissions +RUN chown -R grafana:root /etc/grafana/provisioning + +USER grafana + +# Environment variables. +# Admin credentials are NOT baked into the image: set GF_SECURITY_ADMIN_USER +# and GF_SECURITY_ADMIN_PASSWORD on the Railway service (Grafana reads GF_* +# env vars natively). +ENV GF_AUTH_ANONYMOUS_ENABLED=true +ENV GF_AUTH_ANONYMOUS_ORG_ROLE=Viewer + +EXPOSE 3000 diff --git a/harnesses/bridge-monitor/deploy/grafana/provisioning/dashboards/bridge-latency.json b/harnesses/bridge-monitor/deploy/grafana/provisioning/dashboards/bridge-latency.json new file mode 100644 index 00000000..ea8ad0a2 --- /dev/null +++ b/harnesses/bridge-monitor/deploy/grafana/provisioning/dashboards/bridge-latency.json @@ -0,0 +1,2044 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "spanNulls": true + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 2000 + } + ] + }, + "unit": "suffix: ms", + "decimals": 0 + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#228be6" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*relay.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#fd7e14" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*lifi.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#40c057" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*debridge.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#7950f2" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "calcs": [ + "mean", + "lastNotNull" + ], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { + "mode": "single" + } + }, + "targets": [ + { + "expr": "max_over_time(bridge_quote_latency_ms_sum[24h]) / max_over_time(bridge_quote_latency_ms_count[24h])", + "legendFormat": "{{bridge}} {{from_chain}}→{{to_chain}} ({{amount_usd}}$) avg", + "refId": "A" + } + ], + "title": "Quote Latency (p50/p95)", + "type": "timeseries" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "spanNulls": true + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "currencyUSD" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#228be6" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*relay.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#fd7e14" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*lifi.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#40c057" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*debridge.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#7950f2" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 3, + "options": { + "legend": { + "calcs": [ + "mean", + "lastNotNull" + ], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { + "mode": "single" + } + }, + "targets": [ + { + "expr": "bridge_fees_usd", + "legendFormat": "{{bridge}} {{from_chain}}→{{to_chain}} ({{amount_usd}}$)", + "refId": "A" + } + ], + "title": "Bridge Fees (USD)", + "type": "timeseries" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "spanNulls": true + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 2 + }, + { + "color": "red", + "value": 5 + } + ] + }, + "unit": "percent" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#228be6" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*relay.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#fd7e14" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*lifi.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#40c057" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*debridge.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#7950f2" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 4, + "options": { + "legend": { + "calcs": [ + "mean", + "lastNotNull" + ], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { + "mode": "single" + } + }, + "targets": [ + { + "expr": "bridge_fees_percent", + "legendFormat": "{{bridge}} {{from_chain}}→{{to_chain}} ({{amount_usd}}$)", + "refId": "A" + } + ], + "title": "Bridge Fees (%)", + "type": "timeseries" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "spanNulls": true + }, + "mappings": [], + "unit": "short" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#228be6" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*relay.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#fd7e14" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*lifi.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#40c057" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*debridge.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#7950f2" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 6, + "options": { + "legend": { + "calcs": [ + "sum" + ], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { + "mode": "single" + } + }, + "targets": [ + { + "expr": "rate(bridge_success_total[5m])", + "legendFormat": "{{bridge}} {{from_chain}}→{{to_chain}} success", + "refId": "A" + }, + { + "expr": "rate(bridge_reverts_total[5m])", + "legendFormat": "{{bridge}} {{from_chain}}→{{to_chain}} reverts", + "refId": "B" + }, + { + "expr": "rate(bridge_errors_total[5m])", + "legendFormat": "{{bridge}} {{from_chain}}→{{to_chain}} errors", + "refId": "C" + } + ], + "title": "Success/Revert/Error Rate", + "type": "timeseries" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 1, + "pointSize": 5, + "showPoints": "never", + "spanNulls": true + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 120000 + } + ] + }, + "unit": "suffix: ms", + "decimals": 0 + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#228be6" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*relay.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#fd7e14" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*lifi.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#40c057" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*debridge.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#7950f2" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 16 + }, + "id": 7, + "options": { + "legend": { + "calcs": [ + "mean", + "lastNotNull" + ], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { + "mode": "single" + } + }, + "targets": [ + { + "expr": "max_over_time(bridge_execution_latency_ms_sum[24h]) / max_over_time(bridge_execution_latency_ms_count[24h])", + "legendFormat": "{{bridge}} {{from_chain}}→{{to_chain}} ({{amount_usd}}$) avg", + "refId": "A" + } + ], + "title": "Execution Latency (All)", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 24 + }, + "id": 20, + "panels": [], + "title": "📊 Execution by Amount Tier", + "type": "row" + }, + { + "datasource": "Prometheus", + "description": "Daily at 10:00 UTC", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "points", + "fillOpacity": 0, + "lineWidth": 2, + "pointSize": 8, + "showPoints": "always", + "spanNulls": false, + "scaleDistribution": { + "type": "log", + "log": 2 + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 120000 + } + ] + }, + "unit": "suffix: ms", + "decimals": 0 + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#228be6" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*relay.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#fd7e14" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*lifi.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#40c057" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*debridge.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#7950f2" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 25 + }, + "id": 21, + "options": { + "legend": { + "calcs": [ + "mean", + "lastNotNull" + ], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { + "mode": "single" + } + }, + "targets": [ + { + "expr": "max_over_time(bridge_execution_latency_ms_sum{amount_usd=\"5\"}[24h]) / max_over_time(bridge_execution_latency_ms_count{amount_usd=\"5\"}[24h])", + "legendFormat": "{{bridge}} {{from_chain}}→{{to_chain}} avg", + "refId": "A" + } + ], + "title": "⚡ $5 Execution (Daily)", + "type": "timeseries" + }, + { + "datasource": "Prometheus", + "description": "Monday & Thursday at 10:00 UTC", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "points", + "fillOpacity": 0, + "lineWidth": 2, + "pointSize": 8, + "showPoints": "always", + "spanNulls": false, + "scaleDistribution": { + "type": "log", + "log": 2 + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 120000 + } + ] + }, + "unit": "suffix: ms", + "decimals": 0 + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#228be6" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*relay.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#fd7e14" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*lifi.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#40c057" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*debridge.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#7950f2" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 25 + }, + "id": 22, + "options": { + "legend": { + "calcs": [ + "mean", + "lastNotNull" + ], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { + "mode": "single" + } + }, + "targets": [ + { + "expr": "max_over_time(bridge_execution_latency_ms_sum{amount_usd=\"50\"}[24h]) / max_over_time(bridge_execution_latency_ms_count{amount_usd=\"50\"}[24h])", + "legendFormat": "{{bridge}} {{from_chain}}→{{to_chain}} avg", + "refId": "A" + } + ], + "title": "💰 $50 Execution (Mon/Thu)", + "type": "timeseries" + }, + { + "datasource": "Prometheus", + "description": "Monday at 10:00 UTC (weekly)", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "points", + "fillOpacity": 0, + "lineWidth": 2, + "pointSize": 8, + "showPoints": "always", + "spanNulls": false, + "scaleDistribution": { + "type": "log", + "log": 2 + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 120000 + } + ] + }, + "unit": "suffix: ms", + "decimals": 0 + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#228be6" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*relay.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#fd7e14" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*lifi.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#40c057" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*debridge.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#7950f2" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 25 + }, + "id": 23, + "options": { + "legend": { + "calcs": [ + "mean", + "lastNotNull" + ], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { + "mode": "single" + } + }, + "targets": [ + { + "expr": "max_over_time(bridge_execution_latency_ms_sum{amount_usd=\"300\"}[24h]) / max_over_time(bridge_execution_latency_ms_count{amount_usd=\"300\"}[24h])", + "legendFormat": "{{bridge}} {{from_chain}}→{{to_chain}} avg", + "refId": "A" + } + ], + "title": "💎 $300 Execution (weekly Mon)", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 33 + }, + "id": 30, + "panels": [], + "title": "🎯 End-to-End Latency by Tier", + "type": "row" + }, + { + "datasource": "Prometheus", + "description": "Quote start → funds received", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "points", + "fillOpacity": 0, + "lineWidth": 2, + "pointSize": 8, + "showPoints": "always", + "spanNulls": false, + "scaleDistribution": { + "type": "log", + "log": 2 + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 180000 + } + ] + }, + "unit": "suffix: ms", + "decimals": 0 + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#228be6" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*relay.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#fd7e14" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*lifi.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#40c057" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*debridge.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#7950f2" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 34 + }, + "id": 31, + "options": { + "legend": { + "calcs": [ + "mean", + "lastNotNull" + ], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { + "mode": "single" + } + }, + "targets": [ + { + "expr": "max_over_time(bridge_e2e_latency_ms_sum{amount_usd=\"5\"}[24h]) / max_over_time(bridge_e2e_latency_ms_count{amount_usd=\"5\"}[24h])", + "legendFormat": "{{bridge}} {{from_chain}}→{{to_chain}} avg", + "refId": "A" + } + ], + "title": "⚡ $5 E2E Latency", + "type": "timeseries" + }, + { + "datasource": "Prometheus", + "description": "Quote start → funds received", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "points", + "fillOpacity": 0, + "lineWidth": 2, + "pointSize": 8, + "showPoints": "always", + "spanNulls": false, + "scaleDistribution": { + "type": "log", + "log": 2 + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 180000 + } + ] + }, + "unit": "suffix: ms", + "decimals": 0 + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#228be6" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*relay.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#fd7e14" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*lifi.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#40c057" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*debridge.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#7950f2" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 34 + }, + "id": 32, + "options": { + "legend": { + "calcs": [ + "mean", + "lastNotNull" + ], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { + "mode": "single" + } + }, + "targets": [ + { + "expr": "max_over_time(bridge_e2e_latency_ms_sum{amount_usd=\"50\"}[24h]) / max_over_time(bridge_e2e_latency_ms_count{amount_usd=\"50\"}[24h])", + "legendFormat": "{{bridge}} {{from_chain}}→{{to_chain}} avg", + "refId": "A" + } + ], + "title": "💰 $50 E2E Latency", + "type": "timeseries" + }, + { + "datasource": "Prometheus", + "description": "Quote start → funds received", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "points", + "fillOpacity": 0, + "lineWidth": 2, + "pointSize": 8, + "showPoints": "always", + "spanNulls": false, + "scaleDistribution": { + "type": "log", + "log": 2 + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 180000 + } + ] + }, + "unit": "suffix: ms", + "decimals": 0 + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#228be6" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*relay.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#fd7e14" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*lifi.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#40c057" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*debridge.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#7950f2" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 34 + }, + "id": 33, + "options": { + "legend": { + "calcs": [ + "mean", + "lastNotNull" + ], + "displayMode": "table", + "placement": "bottom" + }, + "tooltip": { + "mode": "single" + } + }, + "targets": [ + { + "expr": "max_over_time(bridge_e2e_latency_ms_sum{amount_usd=\"300\"}[24h]) / max_over_time(bridge_e2e_latency_ms_count{amount_usd=\"300\"}[24h])", + "legendFormat": "{{bridge}} {{from_chain}}→{{to_chain}} avg", + "refId": "A" + } + ], + "title": "💎 $300 E2E Latency", + "type": "timeseries" + }, + { + "collapsed": false, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 42 + }, + "id": 40, + "panels": [], + "title": "📈 Execution Counts by Tier", + "type": "row" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "blue", + "value": null + } + ] + }, + "unit": "short" + } + }, + "gridPos": { + "h": 6, + "w": 8, + "x": 0, + "y": 43 + }, + "id": 41, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ] + } + }, + "targets": [ + { + "expr": "sum(max_over_time(bridge_success_total{amount_usd=\"5\"}[24h]))", + "legendFormat": "Success", + "refId": "A" + } + ], + "title": "⚡ $5 Executions", + "type": "stat" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + } + }, + "gridPos": { + "h": 6, + "w": 8, + "x": 8, + "y": 43 + }, + "id": 42, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ] + } + }, + "targets": [ + { + "expr": "sum(max_over_time(bridge_success_total{amount_usd=\"50\"}[24h]))", + "legendFormat": "Success", + "refId": "A" + } + ], + "title": "💰 $50 Executions", + "type": "stat" + }, + { + "datasource": "Prometheus", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "purple", + "value": null + } + ] + }, + "unit": "short" + } + }, + "gridPos": { + "h": 6, + "w": 8, + "x": 16, + "y": 43 + }, + "id": 43, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ] + } + }, + "targets": [ + { + "expr": "sum(max_over_time(bridge_success_total{amount_usd=\"300\"}[24h]))", + "legendFormat": "Success", + "refId": "A" + } + ], + "title": "💎 $300 Executions", + "type": "stat" + }, + { + "id": 50, + "type": "row", + "title": "🪙 HyperCore Deposit Benchmark (Arb USDC → HL Perps)", + "collapsed": false, + "panels": [], + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 50 + } + }, + { + "id": 51, + "type": "timeseries", + "datasource": "Prometheus", + "title": "HyperCore Cost % (Mobula vs Relay vs LiFi)", + "description": "Total cost as % of trade size on Arb USDC → HyperCore deposit. Lower is better. Mobula's $0 service fee shines on small tickets where Relay/LiFi take a flat $1.20 (24% on $5).", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 10, + "lineWidth": 2, + "pointSize": 5, + "showPoints": "auto", + "spanNulls": true + }, + "mappings": [], + "unit": "percent", + "decimals": 3 + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#228be6" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*relay.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#fd7e14" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*lifi.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#40c057" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 51 + }, + "options": { + "legend": { + "calcs": [ + "mean", + "lastNotNull" + ], + "displayMode": "table", + "placement": "right" + }, + "tooltip": { + "mode": "multi" + } + }, + "targets": [ + { + "expr": "bridge_cost_percent{to_chain=\"HyperCore\"}", + "legendFormat": "{{bridge}} ${{amount_usd}}", + "refId": "A" + } + ] + }, + { + "id": 52, + "type": "timeseries", + "datasource": "Prometheus", + "title": "HyperCore Quote Latency", + "description": "Quote API latency for HyperCore deposit. Measures only the quote step (broadcast not included for Phase 1 quote-only).", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 5, + "lineWidth": 2, + "pointSize": 5, + "showPoints": "auto", + "spanNulls": true, + "scaleDistribution": { + "type": "log", + "log": 2 + } + }, + "mappings": [], + "unit": "ms", + "decimals": 0 + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#228be6" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*relay.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#fd7e14" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*lifi.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#40c057" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 51 + }, + "options": { + "legend": { + "calcs": [ + "mean", + "lastNotNull" + ], + "displayMode": "table", + "placement": "right" + }, + "tooltip": { + "mode": "multi" + } + }, + "targets": [ + { + "expr": "max_over_time(bridge_quote_latency_ms_sum{to_chain=\"HyperCore\"}[24h]) / max_over_time(bridge_quote_latency_ms_count{to_chain=\"HyperCore\"}[24h])", + "legendFormat": "{{bridge}} ${{amount_usd}} avg", + "refId": "A" + } + ] + }, + { + "id": 53, + "type": "timeseries", + "datasource": "Prometheus", + "title": "HyperCore Fees USD (absolute)", + "description": "Absolute fee paid in USD per provider per tier. Mobula stays flat at gas-only (~$0.10), Relay charges $1.20 flat regardless of amount, LiFi has no route below $50.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 5, + "lineWidth": 2, + "pointSize": 6, + "showPoints": "always", + "spanNulls": true + }, + "mappings": [], + "unit": "currencyUSD", + "decimals": 2 + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#228be6" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*relay.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#fd7e14" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*lifi.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#40c057" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 59 + }, + "options": { + "legend": { + "calcs": [ + "mean", + "lastNotNull" + ], + "displayMode": "table", + "placement": "right" + }, + "tooltip": { + "mode": "multi" + } + }, + "targets": [ + { + "expr": "bridge_cost_usd{to_chain=\"HyperCore\"}", + "legendFormat": "{{bridge}} ${{amount_usd}}", + "refId": "A" + } + ] + }, + { + "id": 54, + "type": "stat", + "datasource": "Prometheus", + "title": "HyperCore Quote Success Rate (24h)", + "description": "Fraction of HyperCore quotes returning successfully over 24h. LiFi will show partial coverage (no route <$50). Debridge skipped (chain not supported).", + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "yellow", + "value": 0.5 + }, + { + "color": "green", + "value": 0.95 + } + ] + }, + "unit": "percentunit", + "decimals": 2 + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#228be6" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*relay.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#fd7e14" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*lifi.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#40c057" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 59 + }, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "vertical", + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ] + }, + "textMode": "auto" + }, + "targets": [ + { + "expr": "avg by (bridge, amount_usd) (avg_over_time(bridge_quote_success{to_chain=\"HyperCore\"}[24h]))", + "legendFormat": "{{bridge}} ${{amount_usd}}", + "refId": "A" + } + ] + } + ], + "refresh": "30s", + "schemaVersion": 27, + "style": "dark", + "tags": [ + "bridge", + "latency", + "monitoring" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-7d", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "Bridge Latency Benchmark", + "uid": "bridge-latency", + "version": 0 +} \ No newline at end of file diff --git a/harnesses/bridge-monitor/deploy/grafana/provisioning/dashboards/bridge-pricing.json b/harnesses/bridge-monitor/deploy/grafana/provisioning/dashboards/bridge-pricing.json new file mode 100644 index 00000000..5453d0a8 --- /dev/null +++ b/harnesses/bridge-monitor/deploy/grafana/provisioning/dashboards/bridge-pricing.json @@ -0,0 +1,1619 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "gnetId": null, + "graphTooltip": 0, + "id": null, + "links": [ + { + "asDropdown": false, + "icon": "external link", + "includeVars": false, + "keepTime": false, + "tags": [], + "targetBlank": true, + "title": "Latency Dashboard", + "tooltip": "View execution latency metrics", + "type": "link", + "url": "/d/bridge-latency" + } + ], + "panels": [ + { + "type": "row", + "title": "\ud83c\udfc6 Provider Ranking \u2014 $5 / $50 / $300 (change Route above)", + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 10, + "collapsed": false + }, + { + "datasource": "Prometheus", + "type": "table", + "title": "Main Ranking Table (one row per provider)", + "description": "One row per provider. Columns show cost in USD & % for each amount. Select a route via the 'Route' variable above. Sorted by $5 cost ascending.", + "gridPos": { + "h": 12, + "w": 24, + "x": 0, + "y": 1 + }, + "id": 11, + "targets": [ + { + "expr": "bridge_cost_usd{from_token=~\"$route\",amount_usd=\"5\"}", + "refId": "A", + "format": "table", + "instant": true, + "legendFormat": "" + }, + { + "expr": "bridge_cost_percent{from_token=~\"$route\",amount_usd=\"5\"}", + "refId": "B", + "format": "table", + "instant": true, + "legendFormat": "" + }, + { + "expr": "bridge_cost_usd{from_token=~\"$route\",amount_usd=\"50\"}", + "refId": "C", + "format": "table", + "instant": true, + "legendFormat": "" + }, + { + "expr": "bridge_cost_percent{from_token=~\"$route\",amount_usd=\"50\"}", + "refId": "D", + "format": "table", + "instant": true, + "legendFormat": "" + }, + { + "expr": "bridge_cost_usd{from_token=~\"$route\",amount_usd=\"300\"}", + "refId": "E", + "format": "table", + "instant": true, + "legendFormat": "" + }, + { + "expr": "bridge_cost_percent{from_token=~\"$route\",amount_usd=\"300\"}", + "refId": "F", + "format": "table", + "instant": true, + "legendFormat": "" + }, + { + "expr": "bridge_estimated_time_ms{from_token=~\"$route\",amount_usd=\"300\"}", + "refId": "G", + "format": "table", + "instant": true, + "legendFormat": "" + } + ], + "transformations": [ + { + "id": "joinByField", + "options": { + "byField": "bridge", + "mode": "outer" + } + }, + { + "id": "organize", + "options": { + "excludeByName": { + "Time 1": true, + "Time 2": true, + "Time 3": true, + "Time 4": true, + "Time 5": true, + "Time 6": true, + "Time 7": true, + "__name__ 1": true, + "__name__ 2": true, + "__name__ 3": true, + "__name__ 4": true, + "__name__ 5": true, + "__name__ 6": true, + "__name__ 7": true, + "job 1": true, + "job 2": true, + "job 3": true, + "job 4": true, + "job 5": true, + "job 6": true, + "job 7": true, + "instance 1": true, + "instance 2": true, + "instance 3": true, + "instance 4": true, + "instance 5": true, + "instance 6": true, + "instance 7": true, + "region 1": true, + "region 2": true, + "region 3": true, + "region 4": true, + "region 5": true, + "region 6": true, + "region 7": true, + "from_chain 1": true, + "from_chain 2": true, + "from_chain 3": true, + "from_chain 4": true, + "from_chain 5": true, + "from_chain 6": true, + "from_chain 7": true, + "to_chain 1": true, + "to_chain 2": true, + "to_chain 3": true, + "to_chain 4": true, + "to_chain 5": true, + "to_chain 6": true, + "to_chain 7": true, + "from_token 1": true, + "from_token 2": true, + "from_token 3": true, + "from_token 4": true, + "from_token 5": true, + "from_token 6": true, + "from_token 7": true, + "to_token 1": true, + "to_token 2": true, + "to_token 3": true, + "to_token 4": true, + "to_token 5": true, + "to_token 6": true, + "to_token 7": true, + "amount_usd 1": true, + "amount_usd 2": true, + "amount_usd 3": true, + "amount_usd 4": true, + "amount_usd 5": true, + "amount_usd 6": true, + "amount_usd 7": true, + "service 1": true, + "service 2": true, + "service 3": true, + "service 4": true, + "service 5": true, + "service 6": true, + "service 7": true, + "service 8": true, + "service 9": true, + "service": true + }, + "indexByName": { + "bridge": 0, + "Value #A": 1, + "Value #B": 2, + "Value #C": 3, + "Value #D": 4, + "Value #E": 5, + "Value #F": 6, + "Value #G": 7 + }, + "renameByName": { + "bridge": "Provider", + "Value #A": "$5 Cost USD", + "Value #B": "$5 Cost %", + "Value #C": "$50 Cost USD", + "Value #D": "$50 Cost %", + "Value #E": "$300 Cost USD", + "Value #F": "$300 Cost %", + "Value #G": "Est Time ms" + } + } + }, + { + "id": "sortBy", + "options": { + "fields": {}, + "sort": [ + { + "field": "$5 Cost USD", + "desc": false + } + ] + } + } + ], + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "displayMode": "auto" + }, + "color": { + "mode": "palette-classic" + } + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "Cost USD" + }, + "properties": [ + { + "id": "unit", + "value": "currencyUSD" + }, + { + "id": "decimals", + "value": 3 + }, + { + "id": "custom.displayMode", + "value": "color-background" + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 0.5 + }, + { + "color": "red", + "value": 2 + } + ] + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "Cost %" + }, + "properties": [ + { + "id": "unit", + "value": "percent" + }, + { + "id": "decimals", + "value": 2 + }, + { + "id": "custom.displayMode", + "value": "color-background" + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 0.5 + }, + { + "color": "red", + "value": 5 + } + ] + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Est Time ms" + }, + "properties": [ + { + "id": "unit", + "value": "ms" + }, + { + "id": "decimals", + "value": 0 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Provider" + }, + "properties": [ + { + "id": "custom.width", + "value": 120 + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#228be6" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*relay.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#fd7e14" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*lifi.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#40c057" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*debridge.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#7950f2" + } + } + ] + } + ] + } + }, + { + "type": "row", + "title": "\ud83c\udf69 Cost Distribution \u2014 $5", + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 13 + }, + "id": 100, + "collapsed": false + }, + { + "datasource": "Prometheus", + "type": "piechart", + "title": "$5 \u2014 USDC (Sol) \u2192 USDC (Base)", + "gridPos": { + "h": 9, + "w": 8, + "x": 0, + "y": 14 + }, + "id": 101, + "targets": [ + { + "expr": "bridge_cost_usd{amount_usd=\"5\",from_chain=\"Solana\",to_chain=\"Base\",from_token=\"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v\"}", + "legendFormat": "{{bridge}}", + "refId": "A" + } + ], + "options": { + "pieType": "donut", + "displayLabels": [ + "name", + "percent" + ], + "legend": { + "displayMode": "table", + "placement": "right", + "values": [ + "value", + "percent" + ] + }, + "tooltip": { + "mode": "single" + }, + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + } + }, + "fieldConfig": { + "defaults": { + "unit": "currencyUSD", + "decimals": 4, + "color": { + "mode": "palette-classic" + } + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#228be6" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*relay.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#fd7e14" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*lifi.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#40c057" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*debridge.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#7950f2" + } + } + ] + } + ] + } + }, + { + "datasource": "Prometheus", + "type": "piechart", + "title": "$5 \u2014 USDC (Base) \u2192 USDT (Arb)", + "gridPos": { + "h": 9, + "w": 8, + "x": 8, + "y": 14 + }, + "id": 102, + "targets": [ + { + "expr": "bridge_cost_usd{amount_usd=\"5\",from_chain=\"Base\",to_chain=\"Arbitrum\"}", + "legendFormat": "{{bridge}}", + "refId": "A" + } + ], + "options": { + "pieType": "donut", + "displayLabels": [ + "name", + "percent" + ], + "legend": { + "displayMode": "table", + "placement": "right", + "values": [ + "value", + "percent" + ] + }, + "tooltip": { + "mode": "single" + }, + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + } + }, + "fieldConfig": { + "defaults": { + "unit": "currencyUSD", + "decimals": 4, + "color": { + "mode": "palette-classic" + } + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#228be6" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*relay.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#fd7e14" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*lifi.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#40c057" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*debridge.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#7950f2" + } + } + ] + } + ] + } + }, + { + "datasource": "Prometheus", + "type": "piechart", + "title": "$5 \u2014 TRUMP (Sol) \u2192 BRETT (Base)", + "gridPos": { + "h": 9, + "w": 8, + "x": 16, + "y": 14 + }, + "id": 103, + "targets": [ + { + "expr": "bridge_cost_usd{amount_usd=\"5\",from_token=\"6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN\"}", + "legendFormat": "{{bridge}}", + "refId": "A" + } + ], + "options": { + "pieType": "donut", + "displayLabels": [ + "name", + "percent" + ], + "legend": { + "displayMode": "table", + "placement": "right", + "values": [ + "value", + "percent" + ] + }, + "tooltip": { + "mode": "single" + }, + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + } + }, + "fieldConfig": { + "defaults": { + "unit": "currencyUSD", + "decimals": 4, + "color": { + "mode": "palette-classic" + } + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#228be6" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*relay.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#fd7e14" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*lifi.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#40c057" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*debridge.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#7950f2" + } + } + ] + } + ] + } + }, + { + "type": "row", + "title": "\ud83c\udf69 Cost Distribution \u2014 $50", + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 23 + }, + "id": 200, + "collapsed": false + }, + { + "datasource": "Prometheus", + "type": "piechart", + "title": "$50 \u2014 USDC (Sol) \u2192 USDC (Base)", + "gridPos": { + "h": 9, + "w": 8, + "x": 0, + "y": 24 + }, + "id": 201, + "targets": [ + { + "expr": "bridge_cost_usd{amount_usd=\"50\",from_chain=\"Solana\",to_chain=\"Base\",from_token=\"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v\"}", + "legendFormat": "{{bridge}}", + "refId": "A" + } + ], + "options": { + "pieType": "donut", + "displayLabels": [ + "name", + "percent" + ], + "legend": { + "displayMode": "table", + "placement": "right", + "values": [ + "value", + "percent" + ] + }, + "tooltip": { + "mode": "single" + }, + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + } + }, + "fieldConfig": { + "defaults": { + "unit": "currencyUSD", + "decimals": 4, + "color": { + "mode": "palette-classic" + } + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#228be6" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*relay.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#fd7e14" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*lifi.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#40c057" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*debridge.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#7950f2" + } + } + ] + } + ] + } + }, + { + "datasource": "Prometheus", + "type": "piechart", + "title": "$50 \u2014 USDC (Base) \u2192 USDT (Arb)", + "gridPos": { + "h": 9, + "w": 8, + "x": 8, + "y": 24 + }, + "id": 202, + "targets": [ + { + "expr": "bridge_cost_usd{amount_usd=\"50\",from_chain=\"Base\",to_chain=\"Arbitrum\"}", + "legendFormat": "{{bridge}}", + "refId": "A" + } + ], + "options": { + "pieType": "donut", + "displayLabels": [ + "name", + "percent" + ], + "legend": { + "displayMode": "table", + "placement": "right", + "values": [ + "value", + "percent" + ] + }, + "tooltip": { + "mode": "single" + }, + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + } + }, + "fieldConfig": { + "defaults": { + "unit": "currencyUSD", + "decimals": 4, + "color": { + "mode": "palette-classic" + } + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#228be6" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*relay.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#fd7e14" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*lifi.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#40c057" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*debridge.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#7950f2" + } + } + ] + } + ] + } + }, + { + "datasource": "Prometheus", + "type": "piechart", + "title": "$50 \u2014 TRUMP (Sol) \u2192 BRETT (Base)", + "gridPos": { + "h": 9, + "w": 8, + "x": 16, + "y": 24 + }, + "id": 203, + "targets": [ + { + "expr": "bridge_cost_usd{amount_usd=\"50\",from_token=\"6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN\"}", + "legendFormat": "{{bridge}}", + "refId": "A" + } + ], + "options": { + "pieType": "donut", + "displayLabels": [ + "name", + "percent" + ], + "legend": { + "displayMode": "table", + "placement": "right", + "values": [ + "value", + "percent" + ] + }, + "tooltip": { + "mode": "single" + }, + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + } + }, + "fieldConfig": { + "defaults": { + "unit": "currencyUSD", + "decimals": 4, + "color": { + "mode": "palette-classic" + } + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#228be6" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*relay.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#fd7e14" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*lifi.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#40c057" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*debridge.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#7950f2" + } + } + ] + } + ] + } + }, + { + "type": "row", + "title": "\ud83c\udf69 Cost Distribution \u2014 $300", + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 33 + }, + "id": 300, + "collapsed": false + }, + { + "datasource": "Prometheus", + "type": "piechart", + "title": "$300 \u2014 USDC (Sol) \u2192 USDC (Base)", + "gridPos": { + "h": 9, + "w": 8, + "x": 0, + "y": 34 + }, + "id": 301, + "targets": [ + { + "expr": "bridge_cost_usd{amount_usd=\"300\",from_chain=\"Solana\",to_chain=\"Base\",from_token=\"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v\"}", + "legendFormat": "{{bridge}}", + "refId": "A" + } + ], + "options": { + "pieType": "donut", + "displayLabels": [ + "name", + "percent" + ], + "legend": { + "displayMode": "table", + "placement": "right", + "values": [ + "value", + "percent" + ] + }, + "tooltip": { + "mode": "single" + }, + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + } + }, + "fieldConfig": { + "defaults": { + "unit": "currencyUSD", + "decimals": 4, + "color": { + "mode": "palette-classic" + } + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#228be6" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*relay.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#fd7e14" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*lifi.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#40c057" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*debridge.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#7950f2" + } + } + ] + } + ] + } + }, + { + "datasource": "Prometheus", + "type": "piechart", + "title": "$300 \u2014 USDC (Base) \u2192 USDT (Arb)", + "gridPos": { + "h": 9, + "w": 8, + "x": 8, + "y": 34 + }, + "id": 302, + "targets": [ + { + "expr": "bridge_cost_usd{amount_usd=\"300\",from_chain=\"Base\",to_chain=\"Arbitrum\"}", + "legendFormat": "{{bridge}}", + "refId": "A" + } + ], + "options": { + "pieType": "donut", + "displayLabels": [ + "name", + "percent" + ], + "legend": { + "displayMode": "table", + "placement": "right", + "values": [ + "value", + "percent" + ] + }, + "tooltip": { + "mode": "single" + }, + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + } + }, + "fieldConfig": { + "defaults": { + "unit": "currencyUSD", + "decimals": 4, + "color": { + "mode": "palette-classic" + } + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#228be6" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*relay.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#fd7e14" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*lifi.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#40c057" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*debridge.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#7950f2" + } + } + ] + } + ] + } + }, + { + "datasource": "Prometheus", + "type": "piechart", + "title": "$300 \u2014 TRUMP (Sol) \u2192 BRETT (Base)", + "gridPos": { + "h": 9, + "w": 8, + "x": 16, + "y": 34 + }, + "id": 303, + "targets": [ + { + "expr": "bridge_cost_usd{amount_usd=\"300\",from_token=\"6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN\"}", + "legendFormat": "{{bridge}}", + "refId": "A" + } + ], + "options": { + "pieType": "donut", + "displayLabels": [ + "name", + "percent" + ], + "legend": { + "displayMode": "table", + "placement": "right", + "values": [ + "value", + "percent" + ] + }, + "tooltip": { + "mode": "single" + }, + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + } + }, + "fieldConfig": { + "defaults": { + "unit": "currencyUSD", + "decimals": 4, + "color": { + "mode": "palette-classic" + } + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#228be6" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*relay.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#fd7e14" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*lifi.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#40c057" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": "(?i).*debridge.*" + }, + "properties": [ + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#7950f2" + } + } + ] + } + ] + } + }, + { + "type": "row", + "title": "\ud83d\udcc8 Cost Evolution Over Time (selected route + amount)", + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 43 + }, + "id": 400, + "collapsed": true, + "panels": [ + { + "datasource": "Prometheus", + "type": "timeseries", + "title": "Cost USD Over Time", + "gridPos": { + "h": 9, + "w": 24, + "x": 0, + "y": 44 + }, + "id": 401, + "targets": [ + { + "expr": "bridge_cost_usd{from_token=~\"$route\",amount_usd=\"$amount\"}", + "legendFormat": "{{bridge}}", + "refId": "A" + } + ], + "fieldConfig": { + "defaults": { + "unit": "currencyUSD", + "custom": { + "drawStyle": "line", + "lineWidth": 2, + "fillOpacity": 10 + } + } + } + } + ] + } + ], + "refresh": "30s", + "schemaVersion": 27, + "style": "dark", + "tags": [ + "bridge", + "pricing", + "ranking" + ], + "templating": { + "list": [ + { + "current": { + "selected": true, + "text": "USDC Sol \u2192 Base", + "value": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" + }, + "hide": 0, + "includeAll": false, + "label": "Route (for ranking table)", + "multi": false, + "name": "route", + "options": [ + { + "selected": true, + "text": "USDC Sol \u2192 Base", + "value": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" + }, + { + "selected": false, + "text": "USDC Base \u2192 USDT Arb", + "value": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" + }, + { + "selected": false, + "text": "TRUMP Sol \u2192 BRETT Base", + "value": "6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN" + } + ], + "query": "USDC Sol \u2192 Base : EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v, USDC Base \u2192 USDT Arb : 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913, TRUMP Sol \u2192 BRETT Base : 6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN", + "skipUrlSync": false, + "type": "custom" + }, + { + "current": { + "selected": true, + "text": "5", + "value": "5" + }, + "hide": 0, + "includeAll": false, + "label": "Amount USD (for time series)", + "multi": false, + "name": "amount", + "options": [ + { + "selected": true, + "text": "5", + "value": "5" + }, + { + "selected": false, + "text": "50", + "value": "50" + }, + { + "selected": false, + "text": "300", + "value": "300" + } + ], + "query": "5, 50, 300", + "skipUrlSync": false, + "type": "custom" + } + ] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "\ud83c\udf09 Bridge Pricing & Ranking", + "uid": "bridge-pricing", + "version": 0 +} \ No newline at end of file diff --git a/harnesses/bridge-monitor/deploy/grafana/provisioning/dashboards/dashboard.yml b/harnesses/bridge-monitor/deploy/grafana/provisioning/dashboards/dashboard.yml new file mode 100644 index 00000000..b6fa1088 --- /dev/null +++ b/harnesses/bridge-monitor/deploy/grafana/provisioning/dashboards/dashboard.yml @@ -0,0 +1,12 @@ +apiVersion: 1 + +providers: + - name: 'Bridge Latency Dashboards' + orgId: 1 + folder: '' + type: file + disableDeletion: false + updateIntervalSeconds: 10 + allowUiUpdates: false + options: + path: /etc/grafana/provisioning/dashboards diff --git a/harnesses/bridge-monitor/deploy/grafana/provisioning/datasources/prometheus.yml b/harnesses/bridge-monitor/deploy/grafana/provisioning/datasources/prometheus.yml new file mode 100644 index 00000000..0672b9c6 --- /dev/null +++ b/harnesses/bridge-monitor/deploy/grafana/provisioning/datasources/prometheus.yml @@ -0,0 +1,9 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: https://prometheus-production-9ffe.up.railway.app + isDefault: true + editable: false diff --git a/harnesses/bridge-monitor/deploy/prometheus/Dockerfile b/harnesses/bridge-monitor/deploy/prometheus/Dockerfile new file mode 100644 index 00000000..4bc07d9e --- /dev/null +++ b/harnesses/bridge-monitor/deploy/prometheus/Dockerfile @@ -0,0 +1,21 @@ +FROM prom/prometheus:v2.49.1 + +USER root + +# Copy config and alert rules (we're already in prometheus folder as root directory) +COPY prometheus.yml /etc/prometheus/prometheus.yml +COPY alert_rules.yml /etc/prometheus/alert_rules.yml + +EXPOSE 9090 + +# Run as root to avoid permission issues with Railway volumes. +# +# Security: this Prom is reachable from the public internet (used by the +# OpenChainBench site as a federation source). We deliberately do NOT enable: +# --web.enable-admin-api -> would expose POST /api/v1/admin/tsdb/delete_series +# (anyone could wipe the dataset) +# --web.enable-lifecycle -> would expose POST /-/quitquit and /-/reload +# (anyone could crash or reconfigure it) +# CORS is restricted to the site origin so a browser tab can't issue +# cross-origin admin-style POSTs even if those flags ever creep back in. +CMD ["--config.file=/etc/prometheus/prometheus.yml", "--storage.tsdb.path=/prometheus", "--storage.tsdb.retention.time=365d", "--web.cors.origin=^https://openchainbench\\.com$"] diff --git a/harnesses/bridge-monitor/deploy/prometheus/alert_rules.yml b/harnesses/bridge-monitor/deploy/prometheus/alert_rules.yml new file mode 100644 index 00000000..b1afbf9e --- /dev/null +++ b/harnesses/bridge-monitor/deploy/prometheus/alert_rules.yml @@ -0,0 +1,130 @@ +groups: + - name: bridge_latency + interval: 30s + rules: + - alert: HighBridgeQuoteLatency + expr: histogram_quantile(0.95, rate(bridge_quote_latency_ms_bucket[5m])) > 2000 + for: 5m + labels: + severity: warning + annotations: + summary: "High bridge quote latency" + description: "{{ $labels.bridge }} quote p95 ({{ $labels.from_chain }} → {{ $labels.to_chain }}) is {{ $value | humanize }}ms" + + - alert: HighBridgeExecutionLatency + expr: histogram_quantile(0.95, rate(bridge_execution_latency_ms_bucket[5m])) > 120000 + for: 5m + labels: + severity: warning + annotations: + summary: "High bridge execution latency" + description: "{{ $labels.bridge }} exec p95 ({{ $labels.from_chain }} → {{ $labels.to_chain }}) is {{ $value | humanize }}ms" + + - alert: HighBridgeFees + expr: bridge_fees_percent > 5 + for: 15m + labels: + severity: warning + annotations: + summary: "High bridge fees" + description: "{{ $labels.bridge }} fees ({{ $labels.from_chain }} → {{ $labels.to_chain }}) = {{ $value }}%" + + - name: bridge_reliability + interval: 30s + rules: + # Computed revert rate over a 1h window from counters (the bridge_revert_rate + # gauge was never populated — see PR fix). clamp_min prevents div-by-zero. + - alert: HighBridgeRevertRate + expr: | + sum by (bridge, from_chain, to_chain) (rate(bridge_reverts_total[1h])) + / + clamp_min(sum by (bridge, from_chain, to_chain) (rate(bridge_reverts_total[1h]) + rate(bridge_success_total[1h])), 0.0001) + > 0.1 + for: 5m + labels: + severity: critical + annotations: + summary: "High bridge revert rate" + description: "{{ $labels.bridge }} revert rate ({{ $labels.from_chain }} → {{ $labels.to_chain }}) = {{ $value | humanizePercentage }}" + + # Three real-execution failures in a row on the same bridge — almost certainly a + # bridge-side outage or our integration broke. Page immediately. + - alert: BridgeConsecutiveFailures + expr: bridge_consecutive_failures >= 3 + for: 1m + labels: + severity: critical + annotations: + summary: "{{ $labels.bridge }}: {{ $value }} consecutive execution failures" + description: "{{ $labels.bridge }} has failed {{ $value }} executions in a row (region: {{ $labels.region }})" + + - alert: BridgeMonitorDown + expr: up{job="bridge-monitor"} == 0 + for: 2m + labels: + severity: critical + annotations: + summary: "Bridge monitor is down" + description: "bridge-monitor unreachable for 2m — scheduler not running, no executions." + + - name: wallet_balance + interval: 30s + rules: + # Any triangle leg below $50 means the next $50 scheduled run on that chain will fail. + - alert: LowTriangleBalance + expr: wallet_balance_usd{token=~"USDC|USDT|USDT0"} < 50 + for: 5m + labels: + severity: warning + annotations: + summary: "Triangle leg low: {{ $labels.chain }}/{{ $labels.token }} = ${{ $value | printf \"%.2f\" }}" + description: "Next $50 test from {{ $labels.chain }} will fail insufficient funds. Rebalance needed." + + - alert: CriticalTriangleBalance + expr: wallet_balance_usd{token=~"USDC|USDT|USDT0"} < 10 + for: 1m + labels: + severity: critical + annotations: + summary: "Triangle leg critically low: {{ $labels.chain }}/{{ $labels.token }} = ${{ $value | printf \"%.2f\" }}" + description: "Even the daily $5 test will fail on this chain." + + # Gas: we keep ~$10 native on each chain. Alert when dropping under $3. + - alert: LowGasBalance + expr: wallet_balance_usd{token=~"ETH|SOL"} < 3 + for: 5m + labels: + severity: warning + annotations: + summary: "Low gas on {{ $labels.chain }}: {{ $labels.token }} = ${{ $value | printf \"%.2f\" }}" + description: "Top up native-token balance — TXs will start failing." + + # R4 meme route is one-way (TRUMP Sol → BRETT Base), no inflow refill. + # Weekly $5 × 3 bridges = $15 per run. Alert at $30 (~2 weekly cycles left). + - alert: LowMemeSourceBalance + expr: wallet_balance_usd{chain="Solana", token="TRUMP"} < 30 + for: 5m + labels: + severity: warning + annotations: + summary: "TRUMP balance low on Solana: ${{ $value | printf \"%.2f\" }}" + description: "R4 TRUMP→BRETT will run out in ~{{ $value | humanize }} / 15 weekly cycles. Manual refill required (one-way route)." + + - alert: CriticalMemeSourceBalance + expr: wallet_balance_usd{chain="Solana", token="TRUMP"} < 15 + for: 1m + labels: + severity: critical + annotations: + summary: "TRUMP critically low: ${{ $value | printf \"%.2f\" }}" + description: "Next weekly R4 run will fail. Bridge TRUMP from another wallet to resume." + + # Balance refresh loop died: last update > 20 min ago (updater runs every 5 min). + - alert: WalletBalanceStale + expr: time() - wallet_balance_last_update_timestamp_seconds > 1200 + for: 1m + labels: + severity: warning + annotations: + summary: "Wallet balance metrics stale" + description: "Balance refresh hasn't run in {{ $value | humanizeDuration }}. Check monitor logs." diff --git a/harnesses/bridge-monitor/deploy/prometheus/prometheus.yml b/harnesses/bridge-monitor/deploy/prometheus/prometheus.yml new file mode 100644 index 00000000..61b5e4f7 --- /dev/null +++ b/harnesses/bridge-monitor/deploy/prometheus/prometheus.yml @@ -0,0 +1,19 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + external_labels: + cluster: 'bridge-latency' + +alerting: + alertmanagers: + - static_configs: + - targets: + - alertmanager.railway.internal:9093 + +rule_files: + - 'alert_rules.yml' + +scrape_configs: + - job_name: 'bridge-monitor' + static_configs: + - targets: ['bridge-monitor.railway.internal:9090'] diff --git a/infrastructure/README.md b/infrastructure/README.md index 83ac2e6a..c1e61ebd 100644 --- a/infrastructure/README.md +++ b/infrastructure/README.md @@ -4,9 +4,13 @@ The single shared service every benchmark depends on: a central Prometheus. ``` infrastructure/ -└── prometheus/ Shared Prometheus that scrapes every harness's /metrics - ├── Dockerfile - └── prometheus.yml Scrape config. one job per benchmark +├── prometheus/ Shared Prometheus that scrapes every harness's /metrics +│ ├── Dockerfile +│ └── prometheus.yml Scrape config. one job per benchmark +├── monitoring/ Full Railway monitoring stack: prometheus + grafana + +│ alertmanager + prom-gateway (see monitoring/README.md) +├── monitoring-ui/ Internal control plane (bench state dashboard, Railway) +└── prom-admin/ Admin UI for per-bench Prom TSDB wipes (Railway) ``` ## How OpenChainBench's data plane works diff --git a/infrastructure/monitoring-ui/.env.example b/infrastructure/monitoring-ui/.env.example new file mode 100644 index 00000000..d1571268 --- /dev/null +++ b/infrastructure/monitoring-ui/.env.example @@ -0,0 +1,16 @@ +# Env vars for the monitoring UI Railway service. NEVER commit real values. + +# Fine-grained GitHub PAT, Contents: read on the OCB repo (+ harness repos). +GITHUB_TOKEN= + +# "username:password" for the basic-auth middleware gate. Unset = gate off (dev only). +ADMIN_BASIC_AUTH= + +# V1 — trigger `vercel --prod` after promotion merges. +VERCEL_TOKEN= + +# V3 — shared token for harness /logs endpoints (sent as X-Logs-Token). +LOGS_TOKEN= + +# V3 — "user:pass" for the OVH Caddy basic auth (HL bench log tailing). +OVH_CADDY_AUTH= diff --git a/infrastructure/monitoring-ui/.gitignore b/infrastructure/monitoring-ui/.gitignore new file mode 100644 index 00000000..c3c6b48d --- /dev/null +++ b/infrastructure/monitoring-ui/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +.next/ +out/ +.env +.env.local +.env*.local +*.log +.DS_Store +.vercel/ +tsconfig.tsbuildinfo diff --git a/infrastructure/monitoring-ui/Dockerfile b/infrastructure/monitoring-ui/Dockerfile new file mode 100644 index 00000000..407f99be --- /dev/null +++ b/infrastructure/monitoring-ui/Dockerfile @@ -0,0 +1,25 @@ +FROM oven/bun:1 AS deps +WORKDIR /app +COPY package.json bun.lock* ./ +RUN bun install --frozen-lockfile || bun install + +FROM oven/bun:1 AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +ENV NEXT_TELEMETRY_DISABLED=1 +RUN bun run build + +FROM node:22-slim AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +RUN groupadd --system --gid 1001 nodejs && useradd --system --uid 1001 nextjs +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +USER nextjs +EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 +CMD ["node", "server.js"] diff --git a/infrastructure/monitoring-ui/README.md b/infrastructure/monitoring-ui/README.md new file mode 100644 index 00000000..69c732cf --- /dev/null +++ b/infrastructure/monitoring-ui/README.md @@ -0,0 +1,54 @@ +# OpenBench monitoring UI + +Internal control plane for OpenChainBench. Railway deploys it straight from this repo (root directory `infrastructure/monitoring-ui`). + +Shows every bench's state on `main` (prod) and `dev` (staging) side by side. Highlights drift, staging-only, prod-only, and missing benches. Pulls live data from GitHub via the contents API. Action buttons promote / demote via auto-merged PRs + Vercel deploy. + +## Railway setup + +1. New service on the existing Railway project. +2. Source: `ChainBench/OpenChainBench`, branch `dev`. +3. **Root Directory**: `infrastructure/monitoring-ui`. +4. Builder: Dockerfile (auto-detected). +5. Env vars: see table below. +6. Generate a public domain. + +## Roadmap + +- V0 — read-only dashboard *(current)* +- V1 — `Promote → main` and `Remove from main` buttons (PR auto-merge + `vercel --prod`) +- V2 — multi-file promotion (YAML + suggested shared deps) +- V3 — harness ops: redeploy binary on OVH, restart Railway service, tail logs via `/logs?tail=N` +- V4 — audit log + per-bench history view + +## Stack + +- Next.js 15 App Router · TypeScript · Tailwind +- `@octokit/rest` for GitHub state +- Basic-auth middleware gated on `ADMIN_BASIC_AUTH` +- Deploy: Railway via the included `Dockerfile` + +## Env vars + +| Var | Required | Purpose | +|-----|----------|---------| +| `GITHUB_TOKEN` | yes (prod) | fine-grained PAT, `Contents: read` on OCB + mobula-monorepo | +| `ADMIN_BASIC_AUTH` | yes (prod) | `username:password` for the basic-auth gate | +| `VERCEL_TOKEN` | V1 | trigger `vercel --prod` after promotion merges | +| `LOGS_TOKEN` | V3 | shared bearer for harness `/logs` endpoints | +| `OVH_CADDY_AUTH` | V3 | `user:pass` for the OVH Caddy basic-auth (HL bench) | + +## Dev + +```bash +bun install +bun run dev +``` + +## Deploy + +Push to `dev` on `ChainBench/OpenChainBench`. Railway auto-builds the Dockerfile in this subdir. + +## Registry + +`src/lib/registry.ts` maps each bench slug to its YAML path + harness runtime. Update this when a new bench ships. diff --git a/infrastructure/monitoring-ui/bun.lock b/infrastructure/monitoring-ui/bun.lock new file mode 100644 index 00000000..c4490f64 --- /dev/null +++ b/infrastructure/monitoring-ui/bun.lock @@ -0,0 +1,905 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "openbench-monitoring", + "dependencies": { + "@octokit/rest": "21.1.1", + "js-yaml": "4.1.0", + "next": "15.3.9", + "react": "19.0.0", + "react-dom": "19.0.0", + }, + "devDependencies": { + "@types/js-yaml": "4.0.9", + "@types/node": "22.9.0", + "@types/react": "19.0.0", + "@types/react-dom": "19.0.0", + "autoprefixer": "10.4.20", + "eslint": "9.15.0", + "eslint-config-next": "15.3.9", + "postcss": "8.4.49", + "tailwindcss": "3.4.15", + "typescript": "5.6.3", + }, + }, + }, + "packages": { + "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], + + "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@eslint/config-array": ["@eslint/config-array@0.19.2", "", { "dependencies": { "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w=="], + + "@eslint/core": ["@eslint/core@0.9.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-GuUdqkyyzQI5RMIWkHhvTWLCyLo1jNK3vzkSyaExH5kHPDHcuL2VOpHjmMY+y3+NC69qAKToBqldTBgYeLSr9Q=="], + + "@eslint/eslintrc": ["@eslint/eslintrc@3.3.5", "", { "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" } }, "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg=="], + + "@eslint/js": ["@eslint/js@9.15.0", "", {}, "sha512-tMTqrY+EzbXmKJR5ToI8lxu7jaN5EdmrBFJpQk5JmSlyLsx6o4t27r883K5xsLuCYCpfKBCGswMSWXsM+jB7lg=="], + + "@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.2.8", "", { "dependencies": { "@eslint/core": "^0.13.0", "levn": "^0.4.1" } }, "sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA=="], + + "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], + + "@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="], + + "@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + + "@next/env": ["@next/env@15.3.9", "", {}, "sha512-I7wMCjlHc85EvAebNYJCRBZ+shdrGhcIXBviWmDzGYXwTQ+WrYpfg1LBOnTK1Bn3b+ud5apesNObXKEGqi/C3g=="], + + "@next/eslint-plugin-next": ["@next/eslint-plugin-next@15.3.9", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-9D0UQDmyCOQ4skK/delfp/kXINQQUC5Z0TwwazUbSHBeOojq8fQlZLfbpg3+jldwsmdewdIgjNJ6AzUH6OKe1w=="], + + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.3.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lM/8tilIsqBq+2nq9kbTW19vfwFve0NR7MxfkuSUbRSgXlMQoJYg+31+++XwKVSXk4uT23G2eF/7BRIKdn8t8w=="], + + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.3.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-WhwegPQJ5IfoUNZUVsI9TRAlKpjGVK0tpJTL6KeiC4cux9774NYE9Wu/iCfIkL/5J8rPAkqZpG7n+EfiAfidXA=="], + + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.3.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-LVD6uMOZ7XePg3KWYdGuzuvVboxujGjbcuP2jsPAN3MnLdLoZUXKRc6ixxfs03RH7qBdEHCZjyLP/jBdCJVRJQ=="], + + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.3.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-k8aVScYZ++BnS2P69ClK7v4nOu702jcF9AIHKu6llhHEtBSmM2zkPGl9yoqbSU/657IIIb0QHpdxEr0iW9z53A=="], + + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.3.5", "", { "os": "linux", "cpu": "x64" }, "sha512-2xYU0DI9DGN/bAHzVwADid22ba5d/xrbrQlr2U+/Q5WkFUzeL0TDR963BdrtLS/4bMmKZGptLeg6282H/S2i8A=="], + + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.3.5", "", { "os": "linux", "cpu": "x64" }, "sha512-TRYIqAGf1KCbuAB0gjhdn5Ytd8fV+wJSM2Nh2is/xEqR8PZHxfQuaiNhoF50XfY90sNpaRMaGhF6E+qjV1b9Tg=="], + + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.3.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-h04/7iMEUSMY6fDGCvdanKqlO1qYvzNxntZlCzfE8i5P0uqzVQWQquU1TIhlz0VqGQGXLrFDuTJVONpqGqjGKQ=="], + + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.3.5", "", { "os": "win32", "cpu": "x64" }, "sha512-5fhH6fccXxnX2KhllnGhkYMndhOiLOLEiVGYjP2nizqeGWkN10sA9taATlXwake2E2XMvYZjjz0Uj7T0y+z1yw=="], + + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + + "@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="], + + "@octokit/auth-token": ["@octokit/auth-token@5.1.2", "", {}, "sha512-JcQDsBdg49Yky2w2ld20IHAlwr8d/d8N6NiOXbtuoPCqzbsiJgF633mVUw3x4mo0H5ypataQIX7SFu3yy44Mpw=="], + + "@octokit/core": ["@octokit/core@6.1.6", "", { "dependencies": { "@octokit/auth-token": "^5.0.0", "@octokit/graphql": "^8.2.2", "@octokit/request": "^9.2.3", "@octokit/request-error": "^6.1.8", "@octokit/types": "^14.0.0", "before-after-hook": "^3.0.2", "universal-user-agent": "^7.0.0" } }, "sha512-kIU8SLQkYWGp3pVKiYzA5OSaNF5EE03P/R8zEmmrG6XwOg5oBjXyQVVIauQ0dgau4zYhpZEhJrvIYt6oM+zZZA=="], + + "@octokit/endpoint": ["@octokit/endpoint@10.1.4", "", { "dependencies": { "@octokit/types": "^14.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-OlYOlZIsfEVZm5HCSR8aSg02T2lbUWOsCQoPKfTXJwDzcHQBrVBGdGXb89dv2Kw2ToZaRtudp8O3ZIYoaOjKlA=="], + + "@octokit/graphql": ["@octokit/graphql@8.2.2", "", { "dependencies": { "@octokit/request": "^9.2.3", "@octokit/types": "^14.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-Yi8hcoqsrXGdt0yObxbebHXFOiUA+2v3n53epuOg1QUgOB6c4XzvisBNVXJSl8RYA5KrDuSL2yq9Qmqe5N0ryA=="], + + "@octokit/openapi-types": ["@octokit/openapi-types@25.1.0", "", {}, "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA=="], + + "@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@11.6.0", "", { "dependencies": { "@octokit/types": "^13.10.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-n5KPteiF7pWKgBIBJSk8qzoZWcUkza2O6A0za97pMGVrGfPdltxrfmfF5GucHYvHGZD8BdaZmmHGz5cX/3gdpw=="], + + "@octokit/plugin-request-log": ["@octokit/plugin-request-log@5.3.1", "", { "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-n/lNeCtq+9ofhC15xzmJCNKP2BWTv8Ih2TTy+jatNCCq/gQP/V7rK3fjIfuz0pDWDALO/o/4QY4hyOF6TQQFUw=="], + + "@octokit/plugin-rest-endpoint-methods": ["@octokit/plugin-rest-endpoint-methods@13.5.0", "", { "dependencies": { "@octokit/types": "^13.10.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-9Pas60Iv9ejO3WlAX3maE1+38c5nqbJXV5GrncEfkndIpZrJ/WPMRd2xYDcPPEt5yzpxcjw9fWNoPhsSGzqKqw=="], + + "@octokit/request": ["@octokit/request@9.2.4", "", { "dependencies": { "@octokit/endpoint": "^10.1.4", "@octokit/request-error": "^6.1.8", "@octokit/types": "^14.0.0", "fast-content-type-parse": "^2.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-q8ybdytBmxa6KogWlNa818r0k1wlqzNC+yNkcQDECHvQo8Vmstrg18JwqJHdJdUiHD2sjlwBgSm9kHkOKe2iyA=="], + + "@octokit/request-error": ["@octokit/request-error@6.1.8", "", { "dependencies": { "@octokit/types": "^14.0.0" } }, "sha512-WEi/R0Jmq+IJKydWlKDmryPcmdYSVjL3ekaiEL1L9eo1sUnqMJ+grqmC9cjk7CA7+b2/T397tO5d8YLOH3qYpQ=="], + + "@octokit/rest": ["@octokit/rest@21.1.1", "", { "dependencies": { "@octokit/core": "^6.1.4", "@octokit/plugin-paginate-rest": "^11.4.2", "@octokit/plugin-request-log": "^5.3.1", "@octokit/plugin-rest-endpoint-methods": "^13.3.0" } }, "sha512-sTQV7va0IUVZcntzy1q3QqPm/r8rWtDCqpRAmb8eXXnKkjoQEtFe3Nt5GTVsHft+R6jJoHeSiVLcgcvhtue/rg=="], + + "@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="], + + "@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], + + "@rushstack/eslint-patch": ["@rushstack/eslint-patch@1.16.1", "", {}, "sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag=="], + + "@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="], + + "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + + "@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="], + + "@types/node": ["@types/node@22.9.0", "", { "dependencies": { "undici-types": "~6.19.8" } }, "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ=="], + + "@types/react": ["@types/react@19.0.0", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-MY3oPudxvMYyesqs/kW1Bh8y9VqSmf+tzqw3ae8a9DZW68pUe3zAdHeI1jc6iAysuRdACnVknHP8AhwD4/dxtg=="], + + "@types/react-dom": ["@types/react-dom@19.0.0", "", { "dependencies": { "@types/react": "*" } }, "sha512-1KfiQKsH1o00p9m5ag12axHQSb3FOU9H20UTrujVSkNhuCrRHiQWFqgEnTNK5ZNfnzZv8UWrnXVqCmCF9fgY3w=="], + + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.60.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.60.1", "@typescript-eslint/type-utils": "8.60.1", "@typescript-eslint/utils": "8.60.1", "@typescript-eslint/visitor-keys": "8.60.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.60.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-JQ4S5GB0tfjO8BuJ4fcX+HodkzJjYBV+7OJ+wLygaX7OGQ7FudyHL4NSCA6ob+w3Yn+5MkKIozOwQhXeM7opVg=="], + + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.60.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.60.1", "@typescript-eslint/types": "8.60.1", "@typescript-eslint/typescript-estree": "8.60.1", "@typescript-eslint/visitor-keys": "8.60.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA=="], + + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.60.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.60.1", "@typescript-eslint/types": "^8.60.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-eXkTH2bxmXlqD1RnOPmLZ9ZM9D3VwSx04JOwBnP9RQ+yUA5a2Mu7SfW8uaV2Aon53NJzZlZYuX7tn91Izf+xaw=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.60.1", "", { "dependencies": { "@typescript-eslint/types": "8.60.1", "@typescript-eslint/visitor-keys": "8.60.1" } }, "sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.60.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA=="], + + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.60.1", "", { "dependencies": { "@typescript-eslint/types": "8.60.1", "@typescript-eslint/typescript-estree": "8.60.1", "@typescript-eslint/utils": "8.60.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-sdwTrpjosW7ANQYJ39ZBF1ZyEMEGVB2UsikrserVM/30a/F1dTLnu9bGxEdosugyu5caigjLrR2qiD11asjI1A=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.60.1", "", {}, "sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.60.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.60.1", "@typescript-eslint/tsconfig-utils": "8.60.1", "@typescript-eslint/types": "8.60.1", "@typescript-eslint/visitor-keys": "8.60.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-alpRkfG8hlVE5kdJW2GkfgDgXxold3e8e4l6EnmhRmRLbekgAPCCGDVD++sABy9FcgPFroq+uFcCSM1vR57Cew=="], + + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.60.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.60.1", "@typescript-eslint/types": "8.60.1", "@typescript-eslint/typescript-estree": "8.60.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.60.1", "", { "dependencies": { "@typescript-eslint/types": "8.60.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag=="], + + "@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.12.2", "", { "os": "android", "cpu": "arm" }, "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w=="], + + "@unrs/resolver-binding-android-arm64": ["@unrs/resolver-binding-android-arm64@1.12.2", "", { "os": "android", "cpu": "arm64" }, "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ=="], + + "@unrs/resolver-binding-darwin-arm64": ["@unrs/resolver-binding-darwin-arm64@1.12.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w=="], + + "@unrs/resolver-binding-darwin-x64": ["@unrs/resolver-binding-darwin-x64@1.12.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA=="], + + "@unrs/resolver-binding-freebsd-x64": ["@unrs/resolver-binding-freebsd-x64@1.12.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg=="], + + "@unrs/resolver-binding-linux-arm-gnueabihf": ["@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2", "", { "os": "linux", "cpu": "arm" }, "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A=="], + + "@unrs/resolver-binding-linux-arm-musleabihf": ["@unrs/resolver-binding-linux-arm-musleabihf@1.12.2", "", { "os": "linux", "cpu": "arm" }, "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g=="], + + "@unrs/resolver-binding-linux-arm64-gnu": ["@unrs/resolver-binding-linux-arm64-gnu@1.12.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg=="], + + "@unrs/resolver-binding-linux-arm64-musl": ["@unrs/resolver-binding-linux-arm64-musl@1.12.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA=="], + + "@unrs/resolver-binding-linux-loong64-gnu": ["@unrs/resolver-binding-linux-loong64-gnu@1.12.2", "", { "os": "linux", "cpu": "none" }, "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q=="], + + "@unrs/resolver-binding-linux-loong64-musl": ["@unrs/resolver-binding-linux-loong64-musl@1.12.2", "", { "os": "linux", "cpu": "none" }, "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew=="], + + "@unrs/resolver-binding-linux-ppc64-gnu": ["@unrs/resolver-binding-linux-ppc64-gnu@1.12.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg=="], + + "@unrs/resolver-binding-linux-riscv64-gnu": ["@unrs/resolver-binding-linux-riscv64-gnu@1.12.2", "", { "os": "linux", "cpu": "none" }, "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A=="], + + "@unrs/resolver-binding-linux-riscv64-musl": ["@unrs/resolver-binding-linux-riscv64-musl@1.12.2", "", { "os": "linux", "cpu": "none" }, "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w=="], + + "@unrs/resolver-binding-linux-s390x-gnu": ["@unrs/resolver-binding-linux-s390x-gnu@1.12.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw=="], + + "@unrs/resolver-binding-linux-x64-gnu": ["@unrs/resolver-binding-linux-x64-gnu@1.12.2", "", { "os": "linux", "cpu": "x64" }, "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ=="], + + "@unrs/resolver-binding-linux-x64-musl": ["@unrs/resolver-binding-linux-x64-musl@1.12.2", "", { "os": "linux", "cpu": "x64" }, "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A=="], + + "@unrs/resolver-binding-openharmony-arm64": ["@unrs/resolver-binding-openharmony-arm64@1.12.2", "", { "os": "none", "cpu": "arm64" }, "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ=="], + + "@unrs/resolver-binding-wasm32-wasi": ["@unrs/resolver-binding-wasm32-wasi@1.12.2", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A=="], + + "@unrs/resolver-binding-win32-arm64-msvc": ["@unrs/resolver-binding-win32-arm64-msvc@1.12.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g=="], + + "@unrs/resolver-binding-win32-ia32-msvc": ["@unrs/resolver-binding-win32-ia32-msvc@1.12.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g=="], + + "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.12.2", "", { "os": "win32", "cpu": "x64" }, "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA=="], + + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], + + "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], + + "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + + "array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="], + + "array-includes": ["array-includes@3.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.0", "es-object-atoms": "^1.1.1", "get-intrinsic": "^1.3.0", "is-string": "^1.1.1", "math-intrinsics": "^1.1.0" } }, "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ=="], + + "array.prototype.findlast": ["array.prototype.findlast@1.2.5", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "es-shim-unscopables": "^1.0.2" } }, "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ=="], + + "array.prototype.findlastindex": ["array.prototype.findlastindex@1.2.6", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-shim-unscopables": "^1.1.0" } }, "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ=="], + + "array.prototype.flat": ["array.prototype.flat@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg=="], + + "array.prototype.flatmap": ["array.prototype.flatmap@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg=="], + + "array.prototype.tosorted": ["array.prototype.tosorted@1.1.4", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3", "es-errors": "^1.3.0", "es-shim-unscopables": "^1.0.2" } }, "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA=="], + + "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="], + + "ast-types-flow": ["ast-types-flow@0.0.8", "", {}, "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ=="], + + "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], + + "autoprefixer": ["autoprefixer@10.4.20", "", { "dependencies": { "browserslist": "^4.23.3", "caniuse-lite": "^1.0.30001646", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", "picocolors": "^1.0.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g=="], + + "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], + + "axe-core": ["axe-core@4.12.0", "", {}, "sha512-FTavr/7Ba0IptwGOPxnQvdyW2tAsdLBMTBXz7rKH6xJ2skpyxpBxyHkDdBs4lf69yRqYpkqCdfhnwS8YULGOmg=="], + + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.33", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw=="], + + "before-after-hook": ["before-after-hook@3.0.2", "", {}, "sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A=="], + + "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], + + "brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], + + "busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="], + + "call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + + "camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001793", "", {}, "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA=="], + + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], + + "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], + + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="], + + "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], + + "data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="], + + "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], + + "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="], + + "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], + + "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.367", "", {}, "sha512-4Mk/mrynCNQ+atY40D3UpmhLWB6AHMbYMlIrPhHcMF6x0L7O0b052FCAsxw1LlaR++UFuNg3D/A6XCuGDa0guQ=="], + + "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + + "es-abstract": ["es-abstract@1.24.2", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-iterator-helpers": ["es-iterator-helpers@1.3.2", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.2", "es-errors": "^1.3.0", "es-set-tostringtag": "^2.1.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.3.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "iterator.prototype": "^1.1.5", "math-intrinsics": "^1.1.0" } }, "sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw=="], + + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + + "es-shim-unscopables": ["es-shim-unscopables@1.1.0", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw=="], + + "es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@9.15.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.19.0", "@eslint/core": "^0.9.0", "@eslint/eslintrc": "^3.2.0", "@eslint/js": "9.15.0", "@eslint/plugin-kit": "^0.2.3", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.1", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.5", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.2.0", "eslint-visitor-keys": "^4.2.0", "espree": "^10.3.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-7CrWySmIibCgT1Os28lUU6upBshZ+GxybLOrmRzi08kS8MBuO8QA7pXEgYgY5W8vK3e74xv0lpjo9DbaGU9Rkw=="], + + "eslint-config-next": ["eslint-config-next@15.3.9", "", { "dependencies": { "@next/eslint-plugin-next": "15.3.9", "@rushstack/eslint-patch": "^1.10.3", "@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.31.0", "eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-react": "^7.37.0", "eslint-plugin-react-hooks": "^5.0.0" }, "peerDependencies": { "eslint": "^7.23.0 || ^8.0.0 || ^9.0.0", "typescript": ">=3.3.1" }, "optionalPeers": ["typescript"] }, "sha512-tY/893UZ6rcfJd+G5c1KdRDceEJu3TerrWp+MCzElJD0KpPfrrHMJ8Tq2dTn0bcdRr/wElp0qrXM2vWVf/9uXw=="], + + "eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.10", "", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.16.1", "resolve": "^2.0.0-next.6" } }, "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ=="], + + "eslint-import-resolver-typescript": ["eslint-import-resolver-typescript@3.10.1", "", { "dependencies": { "@nolyfill/is-core-module": "1.0.39", "debug": "^4.4.0", "get-tsconfig": "^4.10.0", "is-bun-module": "^2.0.0", "stable-hash": "^0.0.5", "tinyglobby": "^0.2.13", "unrs-resolver": "^1.6.2" }, "peerDependencies": { "eslint": "*", "eslint-plugin-import": "*", "eslint-plugin-import-x": "*" }, "optionalPeers": ["eslint-plugin-import", "eslint-plugin-import-x"] }, "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ=="], + + "eslint-module-utils": ["eslint-module-utils@2.13.0", "", { "dependencies": { "debug": "^3.2.7" } }, "sha512-bLohSkT6469rRs8czj0tLTD8vaeIS/whvPRJVjDr7IuoTT1k5DYDERlNycjDj/HkOlvQdYurmfZ/g3fG5bgeLQ=="], + + "eslint-plugin-import": ["eslint-plugin-import@2.32.0", "", { "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", "array.prototype.findlastindex": "^1.2.6", "array.prototype.flat": "^1.3.3", "array.prototype.flatmap": "^1.3.3", "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.9", "eslint-module-utils": "^2.12.1", "hasown": "^2.0.2", "is-core-module": "^2.16.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "object.groupby": "^1.0.3", "object.values": "^1.2.1", "semver": "^6.3.1", "string.prototype.trimend": "^1.0.9", "tsconfig-paths": "^3.15.0" }, "peerDependencies": { "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA=="], + + "eslint-plugin-jsx-a11y": ["eslint-plugin-jsx-a11y@6.10.2", "", { "dependencies": { "aria-query": "^5.3.2", "array-includes": "^3.1.8", "array.prototype.flatmap": "^1.3.2", "ast-types-flow": "^0.0.8", "axe-core": "^4.10.0", "axobject-query": "^4.1.0", "damerau-levenshtein": "^1.0.8", "emoji-regex": "^9.2.2", "hasown": "^2.0.2", "jsx-ast-utils": "^3.3.5", "language-tags": "^1.0.9", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "safe-regex-test": "^1.0.3", "string.prototype.includes": "^2.0.1" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" } }, "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q=="], + + "eslint-plugin-react": ["eslint-plugin-react@7.37.5", "", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="], + + "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="], + + "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + + "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], + + "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "fast-content-type-parse": ["fast-content-type-parse@2.0.1", "", {}, "sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + + "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], + + "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], + + "fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "function.prototype.name": ["function.prototype.name@1.1.8", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="], + + "functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="], + + "generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], + + "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + + "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], + + "has-proto": ["has-proto@1.2.0", "", { "dependencies": { "dunder-proto": "^1.0.0" } }, "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], + + "is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="], + + "is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="], + + "is-bigint": ["is-bigint@1.1.0", "", { "dependencies": { "has-bigints": "^1.0.2" } }, "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ=="], + + "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], + + "is-boolean-object": ["is-boolean-object@1.2.2", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A=="], + + "is-bun-module": ["is-bun-module@2.0.0", "", { "dependencies": { "semver": "^7.7.1" } }, "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ=="], + + "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="], + + "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], + + "is-data-view": ["is-data-view@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "is-typed-array": "^1.1.13" } }, "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw=="], + + "is-date-object": ["is-date-object@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="], + + "is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="], + + "is-negative-zero": ["is-negative-zero@2.0.3", "", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="], + + "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], + + "is-set": ["is-set@2.0.3", "", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="], + + "is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="], + + "is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="], + + "is-symbol": ["is-symbol@1.1.1", "", { "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", "safe-regex-test": "^1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="], + + "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], + + "is-weakmap": ["is-weakmap@2.0.2", "", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="], + + "is-weakref": ["is-weakref@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="], + + "is-weakset": ["is-weakset@2.0.4", "", { "dependencies": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ=="], + + "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "iterator.prototype": ["iterator.prototype@1.1.5", "", { "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "get-proto": "^1.0.0", "has-symbols": "^1.1.0", "set-function-name": "^2.0.2" } }, "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g=="], + + "jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], + + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="], + + "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "language-subtag-registry": ["language-subtag-registry@0.3.23", "", {}, "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ=="], + + "language-tags": ["language-tags@1.0.9", "", { "dependencies": { "language-subtag-registry": "^0.3.20" } }, "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA=="], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + + "lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="], + + "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], + + "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + + "napi-postinstall": ["napi-postinstall@0.3.4", "", { "bin": { "napi-postinstall": "lib/cli.js" } }, "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ=="], + + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "next": ["next@15.3.9", "", { "dependencies": { "@next/env": "15.3.9", "@swc/counter": "0.1.3", "@swc/helpers": "0.5.15", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.3.5", "@next/swc-darwin-x64": "15.3.5", "@next/swc-linux-arm64-gnu": "15.3.5", "@next/swc-linux-arm64-musl": "15.3.5", "@next/swc-linux-x64-gnu": "15.3.5", "@next/swc-linux-x64-musl": "15.3.5", "@next/swc-win32-arm64-msvc": "15.3.5", "@next/swc-win32-x64-msvc": "15.3.5", "sharp": "^0.34.1" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.41.2", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-bat50ogkh2esjfkbqmVocL5QunR9RGCSO2oQKFjKeDcEylIgw3JY6CMfGnzoVfXJ9SDLHI546sHmsk90D2ivwQ=="], + + "node-exports-info": ["node-exports-info@1.6.0", "", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw=="], + + "node-releases": ["node-releases@2.0.47", "", {}, "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og=="], + + "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + + "normalize-range": ["normalize-range@0.1.2", "", {}, "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], + + "object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="], + + "object.entries": ["object.entries@1.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-object-atoms": "^1.1.1" } }, "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw=="], + + "object.fromentries": ["object.fromentries@2.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-object-atoms": "^1.0.0" } }, "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ=="], + + "object.groupby": ["object.groupby@1.0.3", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2" } }, "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ=="], + + "object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="], + + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], + + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + + "pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="], + + "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], + + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], + + "postcss": ["postcss@8.4.49", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="], + + "postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="], + + "postcss-js": ["postcss-js@4.1.0", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="], + + "postcss-load-config": ["postcss-load-config@4.0.2", "", { "dependencies": { "lilconfig": "^3.0.0", "yaml": "^2.3.4" }, "peerDependencies": { "postcss": ">=8.0.9", "ts-node": ">=9.0.0" }, "optionalPeers": ["postcss", "ts-node"] }, "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ=="], + + "postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="], + + "postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="], + + "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], + + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + + "react": ["react@19.0.0", "", {}, "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ=="], + + "react-dom": ["react-dom@19.0.0", "", { "dependencies": { "scheduler": "^0.25.0" }, "peerDependencies": { "react": "^19.0.0" } }, "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ=="], + + "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + + "read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="], + + "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + + "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], + + "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], + + "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], + + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + + "safe-array-concat": ["safe-array-concat@1.1.4", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg=="], + + "safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="], + + "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], + + "scheduler": ["scheduler@0.25.0", "", {}, "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA=="], + + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], + + "set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="], + + "set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="], + + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="], + + "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], + + "streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="], + + "string.prototype.includes": ["string.prototype.includes@2.0.1", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3" } }, "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg=="], + + "string.prototype.matchall": ["string.prototype.matchall@4.0.12", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "regexp.prototype.flags": "^1.5.3", "set-function-name": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA=="], + + "string.prototype.repeat": ["string.prototype.repeat@1.0.0", "", { "dependencies": { "define-properties": "^1.1.3", "es-abstract": "^1.17.5" } }, "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w=="], + + "string.prototype.trim": ["string.prototype.trim@1.2.10", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-object-atoms": "^1.0.0", "has-property-descriptors": "^1.0.2" } }, "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA=="], + + "string.prototype.trimend": ["string.prototype.trimend@1.0.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ=="], + + "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="], + + "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], + + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + + "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], + + "sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="], + + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + + "tailwindcss": ["tailwindcss@3.4.15", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.6", "lilconfig": "^2.1.0", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-r4MeXnfBmSOuKUWmXe6h2CcyfzJCEk4F0pptO5jlnYSIViUkVmsawj80N5h2lO3gwcmSb4n3PuN+e+GC1Guylw=="], + + "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], + + "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], + + "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], + + "tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + + "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], + + "typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="], + + "typed-array-byte-offset": ["typed-array-byte-offset@1.0.4", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.15", "reflect.getprototypeof": "^1.0.9" } }, "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ=="], + + "typed-array-length": ["typed-array-length@1.0.8", "", { "dependencies": { "call-bind": "^1.0.9", "for-each": "^0.3.5", "gopd": "^1.2.0", "is-typed-array": "^1.1.15", "possible-typed-array-names": "^1.1.0", "reflect.getprototypeof": "^1.0.10" } }, "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g=="], + + "typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], + + "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], + + "undici-types": ["undici-types@6.19.8", "", {}, "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw=="], + + "universal-user-agent": ["universal-user-agent@7.0.3", "", {}, "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A=="], + + "unrs-resolver": ["unrs-resolver@1.12.2", "", { "dependencies": { "napi-postinstall": "^0.3.4" }, "optionalDependencies": { "@unrs/resolver-binding-android-arm-eabi": "1.12.2", "@unrs/resolver-binding-android-arm64": "1.12.2", "@unrs/resolver-binding-darwin-arm64": "1.12.2", "@unrs/resolver-binding-darwin-x64": "1.12.2", "@unrs/resolver-binding-freebsd-x64": "1.12.2", "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", "@unrs/resolver-binding-linux-x64-musl": "1.12.2", "@unrs/resolver-binding-openharmony-arm64": "1.12.2", "@unrs/resolver-binding-wasm32-wasi": "1.12.2", "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" } }, "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], + + "which-builtin-type": ["which-builtin-type@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "function.prototype.name": "^1.1.6", "has-tostringtag": "^1.0.2", "is-async-function": "^2.0.0", "is-date-object": "^1.1.0", "is-finalizationregistry": "^1.1.0", "is-generator-function": "^1.0.10", "is-regex": "^1.2.1", "is-weakref": "^1.0.2", "isarray": "^2.0.5", "which-boxed-primitive": "^1.1.0", "which-collection": "^1.0.2", "which-typed-array": "^1.1.16" } }, "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q=="], + + "which-collection": ["which-collection@1.0.2", "", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="], + + "which-typed-array": ["which-typed-array@1.1.21", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.9", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-zbRA8cVm6io/d5W8uIe2hblzN76/Wm3v/yiythQvr+dpBWeqhPSWIDNj4zOyHi4zKbMK6DN34Xsr9jPHJERAEw=="], + + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "@eslint/eslintrc/js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], + + "@eslint/plugin-kit/@eslint/core": ["@eslint/core@0.13.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw=="], + + "@next/eslint-plugin-next/fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="], + + "@octokit/plugin-paginate-rest/@octokit/types": ["@octokit/types@13.10.0", "", { "dependencies": { "@octokit/openapi-types": "^24.2.0" } }, "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA=="], + + "@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@13.10.0", "", { "dependencies": { "@octokit/openapi-types": "^24.2.0" } }, "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA=="], + + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + + "@typescript-eslint/typescript-estree/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], + + "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + + "eslint-import-resolver-node/resolve": ["resolve@2.0.0-next.7", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.2", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ=="], + + "eslint-module-utils/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + + "eslint-plugin-import/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + + "eslint-plugin-react/resolve": ["resolve@2.0.0-next.7", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.2", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ=="], + + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "is-bun-module/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], + + "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], + + "postcss-load-config/lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], + + "sharp/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="], + + "tinyglobby/picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + + "@next/eslint-plugin-next/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "@octokit/plugin-paginate-rest/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="], + + "@octokit/plugin-rest-endpoint-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="], + + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], + + "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + } +} diff --git a/infrastructure/monitoring-ui/next.config.ts b/infrastructure/monitoring-ui/next.config.ts new file mode 100644 index 00000000..568eb0c6 --- /dev/null +++ b/infrastructure/monitoring-ui/next.config.ts @@ -0,0 +1,8 @@ +import type { NextConfig } from "next"; + +const config: NextConfig = { + output: "standalone", + experimental: { serverActions: { bodySizeLimit: "2mb" } }, +}; + +export default config; diff --git a/infrastructure/monitoring-ui/package.json b/infrastructure/monitoring-ui/package.json new file mode 100644 index 00000000..f1128df4 --- /dev/null +++ b/infrastructure/monitoring-ui/package.json @@ -0,0 +1,31 @@ +{ + "name": "openbench-monitoring", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start -p ${PORT:-3000}", + "lint": "next lint", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@octokit/rest": "21.1.1", + "js-yaml": "4.1.0", + "next": "15.3.9", + "react": "19.0.0", + "react-dom": "19.0.0" + }, + "devDependencies": { + "@types/js-yaml": "4.0.9", + "@types/node": "22.9.0", + "@types/react": "19.0.0", + "@types/react-dom": "19.0.0", + "autoprefixer": "10.4.20", + "eslint": "9.15.0", + "eslint-config-next": "15.3.9", + "postcss": "8.4.49", + "tailwindcss": "3.4.15", + "typescript": "5.6.3" + } +} diff --git a/infrastructure/monitoring-ui/postcss.config.js b/infrastructure/monitoring-ui/postcss.config.js new file mode 100644 index 00000000..cce4985f --- /dev/null +++ b/infrastructure/monitoring-ui/postcss.config.js @@ -0,0 +1 @@ +module.exports = { plugins: { tailwindcss: {}, autoprefixer: {} } }; diff --git a/infrastructure/monitoring-ui/public/.gitkeep b/infrastructure/monitoring-ui/public/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/infrastructure/monitoring-ui/railway.json b/infrastructure/monitoring-ui/railway.json new file mode 100644 index 00000000..3a0b58cc --- /dev/null +++ b/infrastructure/monitoring-ui/railway.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://railway.app/railway.schema.json", + "build": { "builder": "DOCKERFILE", "dockerfilePath": "Dockerfile" }, + "deploy": { + "startCommand": "node server.js", + "healthcheckPath": "/healthz", + "healthcheckTimeout": 30, + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 3 + } +} diff --git a/infrastructure/monitoring-ui/src/app/actions.ts b/infrastructure/monitoring-ui/src/app/actions.ts new file mode 100644 index 00000000..1275716b --- /dev/null +++ b/infrastructure/monitoring-ui/src/app/actions.ts @@ -0,0 +1,30 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { promoteBenchToMain, removeBenchFromMain, type PromoteResult } from "@/lib/promote"; +import { REGISTRY } from "@/lib/registry"; + +export type ActionResult = PromoteResult; + +function lookup(slug: string) { + const e = REGISTRY.find((x) => x.slug === slug); + if (e) return e; + // Auto-discovered bench (YAML exists on main or dev but not yet in REGISTRY). + // Promote/remove only need ocbYaml, so we synthesize a minimal entry instead + // of throwing — keeps the row clickable from the dashboard. + return { slug, ocbYaml: `benchmarks/${slug}.yml` }; +} + +export async function promoteAction(slug: string): Promise { + const entry = lookup(slug); + const res = await promoteBenchToMain(slug, entry.ocbYaml); + if (res.ok) revalidatePath("/"); + return res; +} + +export async function removeAction(slug: string): Promise { + const entry = lookup(slug); + const res = await removeBenchFromMain(slug, entry.ocbYaml); + if (res.ok) revalidatePath("/"); + return res; +} diff --git a/infrastructure/monitoring-ui/src/app/api/logs/[slug]/route.ts b/infrastructure/monitoring-ui/src/app/api/logs/[slug]/route.ts new file mode 100644 index 00000000..08125197 --- /dev/null +++ b/infrastructure/monitoring-ui/src/app/api/logs/[slug]/route.ts @@ -0,0 +1,53 @@ +import { NextResponse } from "next/server"; +import { REGISTRY } from "@/lib/registry"; + +export const dynamic = "force-dynamic"; + +export async function GET( + req: Request, + ctx: { params: Promise<{ slug: string }> }, +) { + const { slug } = await ctx.params; + const url = new URL(req.url); + const tail = url.searchParams.get("tail") ?? "300"; + const region = url.searchParams.get("region"); + + const entry = REGISTRY.find((e) => e.slug === slug); + if (!entry) { + return NextResponse.json({ error: "unknown slug" }, { status: 404 }); + } + const h = entry.harness; + if (h.type === "none") { + return NextResponse.json({ error: "no harness for this bench" }, { status: 404 }); + } + + let target = h.logsUrl; + if (h.type === "railway" && region && h.extraRegions) { + const r = h.extraRegions.find((x) => x.region === region); + if (r) target = r.logsUrl; + } + const targetUrl = target.includes("?") ? `${target}&tail=${tail}` : `${target}?tail=${tail}`; + + try { + const upstream = await fetch(targetUrl, { + headers: { + ...(h.type === "railway" + ? { "X-Logs-Token": process.env.LOGS_TOKEN ?? "" } + : h.type === "ovh-systemd" + ? { Authorization: `Basic ${Buffer.from(process.env.OVH_CADDY_AUTH ?? "").toString("base64")}` } + : {}), + }, + cache: "no-store", + }); + const text = await upstream.text(); + return new NextResponse(text, { + status: upstream.status, + headers: { "Content-Type": "text/plain; charset=utf-8" }, + }); + } catch (err) { + return NextResponse.json( + { error: `fetch failed: ${(err as Error).message}` }, + { status: 502 }, + ); + } +} diff --git a/infrastructure/monitoring-ui/src/app/globals.css b/infrastructure/monitoring-ui/src/app/globals.css new file mode 100644 index 00000000..ec77e167 --- /dev/null +++ b/infrastructure/monitoring-ui/src/app/globals.css @@ -0,0 +1,13 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + color-scheme: dark; +} + +body { + background: #0a0a0a; + color: #e5e5e5; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} diff --git a/infrastructure/monitoring-ui/src/app/healthz/route.ts b/infrastructure/monitoring-ui/src/app/healthz/route.ts new file mode 100644 index 00000000..a490a7ac --- /dev/null +++ b/infrastructure/monitoring-ui/src/app/healthz/route.ts @@ -0,0 +1,5 @@ +export const dynamic = "force-static"; + +export function GET() { + return new Response("ok", { status: 200, headers: { "Content-Type": "text/plain" } }); +} diff --git a/infrastructure/monitoring-ui/src/app/layout.tsx b/infrastructure/monitoring-ui/src/app/layout.tsx new file mode 100644 index 00000000..25725979 --- /dev/null +++ b/infrastructure/monitoring-ui/src/app/layout.tsx @@ -0,0 +1,16 @@ +import "./globals.css"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "OpenBench Monitoring", + description: "Internal control plane for OCB bench promotion + harness ops", + robots: { index: false, follow: false }, +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/infrastructure/monitoring-ui/src/app/page.tsx b/infrastructure/monitoring-ui/src/app/page.tsx new file mode 100644 index 00000000..1822e744 --- /dev/null +++ b/infrastructure/monitoring-ui/src/app/page.tsx @@ -0,0 +1,298 @@ +import { loadAllBenchRows, type BenchRow, type BenchStatus } from "@/lib/bench-state"; +import { ActionButtons } from "@/components/action-buttons"; +import { LogsButton } from "@/components/logs-modal"; +import { benchPageUrl, diffUrl } from "@/lib/registry"; + +export const revalidate = 30; +export const dynamic = "force-dynamic"; + +const STATUS_INFO: Record = { + synced: { + label: "synced", + tip: "Prod and staging have the exact same YAML. Nothing to do.", + cls: "bg-emerald-900/40 text-emerald-300 border-emerald-700/50", + }, + drift: { + label: "drift", + tip: "Both branches have the YAML but their contents differ (usually staging has changes not yet promoted to prod). Click → prod to push the staging version live.", + cls: "bg-amber-900/40 text-amber-300 border-amber-700/50", + }, + dev_only: { + label: "staging only", + tip: "Bench exists on staging (dev) only. Not visible on openchainbench.com. Click → prod to ship it.", + cls: "bg-sky-900/40 text-sky-300 border-sky-700/50", + }, + prod_only: { + label: "prod only", + tip: "Bench is on prod but not on staging. Unusual state — consider restoring the YAML on dev.", + cls: "bg-fuchsia-900/40 text-fuchsia-300 border-fuchsia-700/50", + }, + missing_everywhere: { + label: "missing", + tip: "No YAML on either branch.", + cls: "bg-neutral-800 text-neutral-400 border-neutral-700", + }, +}; + +function StatusBadge({ status }: { status: BenchStatus }) { + const m = STATUS_INFO[status]; + return ( + + {m.label} + + ); +} + +function fmtDate(iso: string) { + const d = new Date(iso); + const now = new Date(); + const diffMs = now.getTime() - d.getTime(); + const min = Math.floor(diffMs / 60000); + if (min < 1) return "just now"; + if (min < 60) return `${min}m ago`; + const h = Math.floor(min / 60); + if (h < 24) return `${h}h ago`; + const days = Math.floor(h / 24); + if (days < 30) return `${days}d ago`; + return d.toLocaleDateString("en-US", { day: "2-digit", month: "short" }); +} + +function Cell({ + slot, + href, + emptyLabel, +}: { + slot: BenchRow["main"]; + href: string; + emptyLabel: string; +}) { + if (!slot.state.exists) { + return {emptyLabel}; + } + const c = slot.lastCommit; + return ( +
+ + ✓ {slot.state.sha.slice(0, 7)} + + + {c && ( +
+ {c.sha} · {fmtDate(c.date)} +
+ {c.message} +
+
+ )} +
+ ); +} + +function HarnessCell({ row }: { row: BenchRow }) { + const h = row.entry.harness; + if (h.type === "none") return ; + if (h.type === "railway") { + const total = 1 + (h.extraRegions?.length ?? 0); + return ( +
+
railway{total > 1 ? ` ×${total}` : ""}
+
{h.service}
+
+ ); + } + return ( +
+
ovh
+
{h.service}
+
+ ); +} + +function SourceCell({ row }: { row: BenchRow }) { + return ( + + source ↗ + + ); +} + +function LogsCell({ row }: { row: BenchRow }) { + const h = row.entry.harness; + if (h.type === "none") { + return ( + + — + + ); + } + return ( + + ); +} + +export default async function HomePage() { + const rows = await loadAllBenchRows(); + const counts = rows.reduce>( + (acc, r) => ({ ...acc, [r.status]: (acc[r.status] ?? 0) + 1 }), + { synced: 0, drift: 0, dev_only: 0, prod_only: 0, missing_everywhere: 0 }, + ); + + return ( +
+
+

OpenBench monitoring

+

+ Bench state on main (prod = openchainbench.com) + and dev (staging-openchainbench.vercel.app). + Refreshed every 30s. +

+
+ ✓ {counts.synced} synced + ⚠ {counts.drift} drift + → {counts.dev_only} staging only + ← {counts.prod_only} prod only + {counts.missing_everywhere > 0 && ( + ∅ {counts.missing_everywhere} missing + )} +
+
+ +
+ + How does it work? + +
+

+ One row per bench. Left side shows its state on main (prod = openchainbench.com), + right side on dev (staging). Click the green SHA to open + the bench page in the matching environment. +

+

+ DRIFT: both branches have the YAML but contents differ. + Usually staging holds updates that haven't been promoted to prod yet. +

+

+ STAGING ONLY: bench exists on staging only. + Invisible on openchainbench.com until you click → prod. +

+

+ → prod: copy the YAML from dev to main, open + an auto-merged PR, and trigger a Vercel prod deploy. Confirmation modal where you must type + PROD before execution. +

+

+ ← remove: delete the YAML on main (kept on dev). + Bench disappears from openchainbench.com after deploy but stays accessible on staging. + Same confirmation modal. +

+

+ Prod ETA ≈ 3-5 min after the click. The merged PR + triggers the prod-deploy.yml GitHub Actions workflow that builds and deploys via + the Vercel CLI. The result panel exposes a follow CI deploy link to watch the run live. +

+

+ source link: opens the harness or YAML source on GitHub. + logs button: hits the harness /logs{" "} + endpoint (X-Logs-Token auth) on the matching Railway / OVH service. Multi-region benches + (aggregator-head-lag, rpc-capabilities, solana-dex-quote-latency) expose one button per region. +

+
+
+ +
+ + + + + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + + + + ))} + +
BenchProd (main)Staging (dev)StatusHarnessSourceLogsActions
+
{row.entry.slug}
+
+ {row.entry.name} +
+ {row.status === "drift" && ( + + view diff ↗ + + )} +
+ + + + + + + + + + + + + +
+
+ +
+ V1 promote / remove · next: harness ops (redeploy, logs, restart) +
+
+ ); +} diff --git a/infrastructure/monitoring-ui/src/components/action-buttons.tsx b/infrastructure/monitoring-ui/src/components/action-buttons.tsx new file mode 100644 index 00000000..70c80e65 --- /dev/null +++ b/infrastructure/monitoring-ui/src/components/action-buttons.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { promoteAction, removeAction, type ActionResult } from "@/app/actions"; +import type { BenchStatus } from "@/lib/bench-state"; + +type Props = { slug: string; status: BenchStatus }; + +export function ActionButtons({ slug, status }: Props) { + const [pending, startTransition] = useTransition(); + const [confirmKind, setConfirmKind] = useState<"promote" | "remove" | null>(null); + const [typed, setTyped] = useState(""); + const [result, setResult] = useState(null); + + function open(kind: "promote" | "remove") { + setConfirmKind(kind); + setTyped(""); + setResult(null); + } + function close() { + setConfirmKind(null); + setTyped(""); + } + + function run() { + if (typed !== "PROD") return; + const fn = confirmKind === "promote" ? promoteAction : removeAction; + startTransition(async () => { + const res = await fn(slug); + setResult(res); + if (res.ok) close(); + }); + } + + const canPromote = status === "dev_only" || status === "drift"; + const canRemove = status === "synced" || status === "drift" || status === "prod_only"; + + return ( +
+
+ {canPromote && ( + + )} + {canRemove && ( + + )} +
+ {result && ( +
+
+ {result.ok ? "✓" : "✗"} +
+
{result.message}
+ {(result.prUrl || result.actionsUrl) && ( +
+ {result.prUrl && ( + + view PR ↗ + + )} + {result.actionsUrl && ( + + follow CI deploy ↗ + + )} +
+ )} +
+
+
+ )} + + {confirmKind && ( +
+
e.stopPropagation()} className="bg-neutral-950 border border-neutral-700 rounded-lg p-6 max-w-md w-full"> +

+ {confirmKind === "promote" ? "Promote to prod" : "Remove from prod"} +

+

+ {slug} +

+

+ {confirmKind === "promote" + ? "Copies the YAML from dev to main, opens an auto-merged PR, and triggers a prod deploy." + : "Removes the YAML from main (kept on dev), opens an auto-merged PR, and triggers a prod deploy."} +

+ + setTyped(e.target.value)} + className="w-full bg-neutral-900 border border-neutral-700 rounded px-2 py-1 text-sm font-mono" + placeholder="PROD" + /> +
+ + +
+ {result && !result.ok && ( +
{result.message}
+ )} +
+
+ )} +
+ ); +} diff --git a/infrastructure/monitoring-ui/src/components/logs-modal.tsx b/infrastructure/monitoring-ui/src/components/logs-modal.tsx new file mode 100644 index 00000000..b983ba84 --- /dev/null +++ b/infrastructure/monitoring-ui/src/components/logs-modal.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { useEffect, useState, useTransition } from "react"; + +type Region = { region: string; service: string; logsUrl: string }; + +type Props = { + slug: string; + service: string; + rawUrl: string; + extraRegions?: Region[]; +}; + +export function LogsButton({ slug, service, rawUrl, extraRegions }: Props) { + const [open, setOpen] = useState(false); + return ( + <> + + {open && ( + setOpen(false)} + /> + )} + + ); +} + +function LogsModal({ + slug, + service, + rawUrl, + extraRegions, + onClose, +}: Props & { onClose: () => void }) { + const regions = [ + { region: "primary", service, rawUrl }, + ...(extraRegions ?? []).map((r) => ({ region: r.region, service: r.service, rawUrl: r.logsUrl })), + ]; + const [region, setRegion] = useState(regions[0].region); + const [tail, setTail] = useState(300); + const [text, setText] = useState("loading…"); + const [error, setError] = useState(null); + const [pending, startTransition] = useTransition(); + + const active = regions.find((r) => r.region === region) ?? regions[0]; + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [onClose]); + + useEffect(() => { + startTransition(async () => { + setError(null); + try { + const qs = new URLSearchParams({ tail: String(tail) }); + if (region !== "primary") qs.set("region", region); + const res = await fetch(`/api/logs/${slug}?${qs}`, { cache: "no-store" }); + const body = await res.text(); + if (!res.ok) { + setError(`HTTP ${res.status}`); + setText(body); + } else { + setText(body || "(empty)"); + } + } catch (e) { + setError((e as Error).message); + setText(""); + } + }); + }, [slug, region, tail]); + + return ( +
+
e.stopPropagation()} + className="bg-neutral-950 border border-neutral-700 rounded-lg w-full max-w-5xl max-h-[85vh] flex flex-col" + > +
+
+

+ {slug}{" "} + ·{" "} + {active.service} +

+ {regions.length > 1 && ( +
+ {regions.map((r) => ( + + ))} +
+ )} + + {pending && loading…} +
+
+ + raw page ↗ + + +
+
+ {error && ( +
+ {error} +
+ )} +
+          {text}
+        
+
+
+ ); +} diff --git a/infrastructure/monitoring-ui/src/lib/bench-state.ts b/infrastructure/monitoring-ui/src/lib/bench-state.ts new file mode 100644 index 00000000..a180885f --- /dev/null +++ b/infrastructure/monitoring-ui/src/lib/bench-state.ts @@ -0,0 +1,98 @@ +import { REGISTRY, OCB_REPO, MOBULA_REPO, type BenchEntry } from "./registry"; +import { fileState, lastCommitForPath, gh, type FileState } from "./github"; + +export type BranchSlot = { + state: FileState; + lastCommit: { sha: string; message: string; date: string; author: string } | null; +}; + +export type BenchStatus = + | "dev_only" + | "prod_only" + | "synced" + | "drift" + | "missing_everywhere"; + +export type BenchRow = { + entry: BenchEntry; + main: BranchSlot; + dev: BranchSlot; + status: BenchStatus; +}; + +function classify(main: FileState, dev: FileState): BenchStatus { + if (!main.exists && !dev.exists) return "missing_everywhere"; + if (main.exists && !dev.exists) return "prod_only"; + if (!main.exists && dev.exists) return "dev_only"; + if (main.exists && dev.exists) { + return main.sha === dev.sha ? "synced" : "drift"; + } + return "missing_everywhere"; +} + +async function rowForBench(entry: BenchEntry): Promise { + const [mainState, devState, mainCommit, devCommit] = await Promise.all([ + fileState(OCB_REPO.owner, OCB_REPO.repo, entry.ocbYaml, "main"), + fileState(OCB_REPO.owner, OCB_REPO.repo, entry.ocbYaml, "dev"), + lastCommitForPath(OCB_REPO.owner, OCB_REPO.repo, entry.ocbYaml, "main"), + lastCommitForPath(OCB_REPO.owner, OCB_REPO.repo, entry.ocbYaml, "dev"), + ]); + return { + entry, + main: { state: mainState, lastCommit: mainCommit }, + dev: { state: devState, lastCommit: devCommit }, + status: classify(mainState, devState), + }; +} + +async function listBenchSlugsFromBranch(branch: "main" | "dev"): Promise { + try { + const res = await gh.repos.getContent({ + ...OCB_REPO, + path: "benchmarks", + ref: branch, + }); + if (!Array.isArray(res.data)) return []; + return res.data + .filter((f) => f.type === "file" && f.name.endsWith(".yml")) + .map((f) => f.name.replace(/\.yml$/, "")); + } catch { + return []; + } +} + +function entryForSlug(slug: string): BenchEntry { + const known = REGISTRY.find((e) => e.slug === slug); + if (known) return known; + // Auto-discovered bench: minimal placeholder entry. + return { + slug, + name: slug, + ocbYaml: `benchmarks/${slug}.yml`, + harness: { type: "none" }, + sourceUrl: `https://github.com/${OCB_REPO.owner}/${OCB_REPO.repo}/blob/dev/benchmarks/${slug}.yml`, + }; +} + +export async function loadAllBenchRows(): Promise { + // Union of slugs found on main + dev, so any bench that lands on either + // branch shows up in the dashboard without a registry edit. Known benches + // still pick up their harness/sourceUrl config from REGISTRY. + const [mainSlugs, devSlugs] = await Promise.all([ + listBenchSlugsFromBranch("main"), + listBenchSlugsFromBranch("dev"), + ]); + const slugSet = new Set([ + ...mainSlugs, + ...devSlugs, + ...REGISTRY.map((e) => e.slug), + ]); + const entries = Array.from(slugSet) + .sort() + .map(entryForSlug); + const rows = await Promise.all(entries.map(rowForBench)); + return rows; +} + +// Re-export to keep the import surface stable. +export { MOBULA_REPO }; diff --git a/infrastructure/monitoring-ui/src/lib/github.ts b/infrastructure/monitoring-ui/src/lib/github.ts new file mode 100644 index 00000000..af895f89 --- /dev/null +++ b/infrastructure/monitoring-ui/src/lib/github.ts @@ -0,0 +1,69 @@ +import { Octokit } from "@octokit/rest"; + +const GH_TOKEN = process.env.GITHUB_TOKEN; +if (!GH_TOKEN && process.env.NODE_ENV === "production") { + console.warn("GITHUB_TOKEN missing — read paths will rate-limit fast"); +} + +export const gh = new Octokit({ auth: GH_TOKEN, userAgent: "openbench-monitoring/0.1" }); + +export type FileState = + | { exists: true; sha: string; size: number; updatedAt?: string } + | { exists: false }; + +export async function fileState( + owner: string, + repo: string, + path: string, + ref: string, +): Promise { + try { + const res = await gh.repos.getContent({ owner, repo, path, ref }); + const data = res.data; + if (Array.isArray(data) || data.type !== "file") return { exists: false }; + return { exists: true, sha: data.sha, size: data.size }; + } catch (err) { + const e = err as { status?: number }; + if (e.status === 404) return { exists: false }; + throw err; + } +} + +export async function fileContent( + owner: string, + repo: string, + path: string, + ref: string, +): Promise { + try { + const res = await gh.repos.getContent({ owner, repo, path, ref }); + const data = res.data; + if (Array.isArray(data) || data.type !== "file" || !("content" in data)) return null; + return Buffer.from(data.content, "base64").toString("utf-8"); + } catch (err) { + const e = err as { status?: number }; + if (e.status === 404) return null; + throw err; + } +} + +export async function lastCommitForPath( + owner: string, + repo: string, + path: string, + ref: string, +): Promise<{ sha: string; message: string; date: string; author: string } | null> { + try { + const res = await gh.repos.listCommits({ owner, repo, path, sha: ref, per_page: 1 }); + const c = res.data[0]; + if (!c) return null; + return { + sha: c.sha.slice(0, 7), + message: c.commit.message.split("\n")[0], + date: c.commit.author?.date ?? "", + author: c.commit.author?.name ?? "unknown", + }; + } catch { + return null; + } +} diff --git a/infrastructure/monitoring-ui/src/lib/promote.ts b/infrastructure/monitoring-ui/src/lib/promote.ts new file mode 100644 index 00000000..b974ca76 --- /dev/null +++ b/infrastructure/monitoring-ui/src/lib/promote.ts @@ -0,0 +1,146 @@ +import { gh, fileContent } from "./github"; +import { OCB_REPO } from "./registry"; + +export type PromoteResult = { + ok: boolean; + prUrl?: string; + mergedSha?: string; + actionsUrl?: string; + message: string; +}; + +const PROD_DEPLOY_ACTIONS_URL = + "https://github.com/ChainBench/OpenChainBench/actions/workflows/prod-deploy.yml"; + +async function getDefaultBranchSha(branch: "main" | "dev"): Promise { + const res = await gh.repos.getBranch({ ...OCB_REPO, branch }); + return res.data.commit.sha; +} + +async function createBranch(name: string, fromSha: string) { + await gh.git.createRef({ ...OCB_REPO, ref: `refs/heads/${name}`, sha: fromSha }); +} + +async function getFileShaOnBranch(path: string, branch: string): Promise { + try { + const res = await gh.repos.getContent({ ...OCB_REPO, path, ref: branch }); + if (Array.isArray(res.data) || res.data.type !== "file") return null; + return res.data.sha; + } catch (err) { + if ((err as { status?: number }).status === 404) return null; + throw err; + } +} + +async function commitFile(opts: { + branch: string; + path: string; + content: string; + shaToReplace?: string | null; + message: string; +}) { + await gh.repos.createOrUpdateFileContents({ + ...OCB_REPO, + branch: opts.branch, + path: opts.path, + message: opts.message, + content: Buffer.from(opts.content, "utf-8").toString("base64"), + sha: opts.shaToReplace ?? undefined, + }); +} + +async function deleteFile(opts: { branch: string; path: string; sha: string; message: string }) { + await gh.repos.deleteFile({ + ...OCB_REPO, + branch: opts.branch, + path: opts.path, + message: opts.message, + sha: opts.sha, + }); +} + +async function openPR(opts: { head: string; base: string; title: string; body: string }) { + const res = await gh.pulls.create({ + ...OCB_REPO, + head: opts.head, + base: opts.base, + title: opts.title, + body: opts.body, + }); + return { number: res.data.number, url: res.data.html_url }; +} + +async function mergePR(number: number): Promise { + const res = await gh.pulls.merge({ ...OCB_REPO, pull_number: number, merge_method: "squash" }); + return res.data.sha; +} + +export async function promoteBenchToMain(slug: string, yamlPath: string): Promise { + const devContent = await fileContent(OCB_REPO.owner, OCB_REPO.repo, yamlPath, "dev"); + if (devContent == null) return { ok: false, message: `not found on dev: ${yamlPath}` }; + + const ts = Date.now(); + const branch = `auto/promote-${slug}-${ts}`; + const mainSha = await getDefaultBranchSha("main"); + await createBranch(branch, mainSha); + + const existingOnMain = await getFileShaOnBranch(yamlPath, "main"); + await commitFile({ + branch, + path: yamlPath, + content: devContent, + shaToReplace: existingOnMain, + message: `chore(${slug}): promote from dev to main`, + }); + + const pr = await openPR({ + head: branch, + base: "main", + title: `promote: ${slug} → main`, + body: `Auto-promotion from dev to main via openbench-monitoring.\n\nContent copied from dev's \`${yamlPath}\`.`, + }); + + const mergedSha = await mergePR(pr.number); + + return { + ok: true, + prUrl: pr.url, + mergedSha, + actionsUrl: PROD_DEPLOY_ACTIONS_URL, + message: `PR #${pr.number} merged into main. CI deploy started — prod will update in ~3-5 min.`, + }; +} + +export async function removeBenchFromMain(slug: string, yamlPath: string): Promise { + const existingSha = await getFileShaOnBranch(yamlPath, "main"); + if (existingSha == null) return { ok: false, message: `not on main: ${yamlPath}` }; + + const ts = Date.now(); + const branch = `auto/remove-${slug}-${ts}`; + const mainSha = await getDefaultBranchSha("main"); + await createBranch(branch, mainSha); + + await deleteFile({ + branch, + path: yamlPath, + sha: existingSha, + message: `chore(${slug}): remove from main (keep on dev)`, + }); + + const pr = await openPR({ + head: branch, + base: "main", + title: `rollback: ${slug} → staging only`, + body: `Auto-rollback from main via openbench-monitoring. YAML remains on dev.`, + }); + + const mergedSha = await mergePR(pr.number); + + return { + ok: true, + prUrl: pr.url, + mergedSha, + actionsUrl: PROD_DEPLOY_ACTIONS_URL, + message: `PR #${pr.number} merged into main. CI deploy started — prod will update in ~3-5 min.`, + }; +} diff --git a/infrastructure/monitoring-ui/src/lib/registry.ts b/infrastructure/monitoring-ui/src/lib/registry.ts new file mode 100644 index 00000000..498d9f4d --- /dev/null +++ b/infrastructure/monitoring-ui/src/lib/registry.ts @@ -0,0 +1,272 @@ +export type HarnessRuntime = + | { + type: "railway"; + service: string; + logsUrl: string; + auth: "logs-token"; + extraRegions?: { region: string; service: string; logsUrl: string }[]; + } + | { type: "ovh-systemd"; host: string; service: string; logsUrl: string; auth: "basic" } + | { type: "none" }; + +export type BenchEntry = { + slug: string; + name: string; + ocbYaml: string; + harness: HarnessRuntime; + /** Public GitHub URL of the harness source code (or YAML spec if no harness). */ + sourceUrl: string; + promPrefix?: string; +}; + +export const OCB_REPO = { owner: "OpenChainBench", repo: "OpenChainBench" } as const; +export const MOBULA_REPO = { owner: "MobulaFi", repo: "mobula-monorepo" } as const; + +export const PROD_URL = "https://openchainbench.com"; +export const STAGING_URL = "https://staging-openchainbench.vercel.app"; + +// Public OCB harness tree. Prefer this for sourceUrl whenever an open-source +// harness exists so the dashboard never leaks paths inside the private monorepo. +const OCB_HARNESS_BASE = `https://github.com/${OCB_REPO.owner}/${OCB_REPO.repo}/tree/main/harnesses`; + +// Private MobulaFi miniapps tree. Only use for benches that don't yet have a +// public OCB harness counterpart (currently network-fees + pm-data-freshness). +const MOBULA_BASE = `https://github.com/${MOBULA_REPO.owner}/${MOBULA_REPO.repo}/tree/dev/miniapps`; + +export function benchPageUrl(branch: "main" | "dev", slug: string) { + const base = branch === "main" ? PROD_URL : STAGING_URL; + return `${base}/benchmarks/${slug}`; +} + +export function diffUrl(slug: string) { + return `https://github.com/${OCB_REPO.owner}/${OCB_REPO.repo}/compare/main...dev?expand=1&file=benchmarks/${slug}.yml`; +} + +const RW = (service: string, host: string): HarnessRuntime => ({ + type: "railway", + service, + logsUrl: `https://${host}/logs`, + auth: "logs-token", +}); + +const RW_MULTI = ( + primary: { service: string; host: string }, + extras: { region: string; service: string; host: string }[], +): HarnessRuntime => ({ + type: "railway", + service: primary.service, + logsUrl: `https://${primary.host}/logs`, + auth: "logs-token", + extraRegions: extras.map((e) => ({ + region: e.region, + service: e.service, + logsUrl: `https://${e.host}/logs`, + })), +}); + +export const REGISTRY: BenchEntry[] = [ + { + slug: "hyperliquid-frontends", + name: "Hyperliquid frontends builder revenue", + ocbYaml: "benchmarks/hyperliquid-frontends.yml", + harness: { + type: "ovh-systemd", + host: "15.235.224.14", + service: "hl-frontends-local", + logsUrl: "http://15.235.224.14:8088/logs", + auth: "basic", + }, + sourceUrl: `${OCB_HARNESS_BASE}/hyperliquid-frontends`, + promPrefix: "hl_frontend_", + }, + { + slug: "network-fees", + name: "Current native transfer fee L1/L2", + ocbYaml: "benchmarks/network-fees.yml", + harness: RW("transaction-fee", "transaction-fee-production.up.railway.app"), + sourceUrl: `${MOBULA_BASE}/transaction-fee`, + promPrefix: "tx_fee_", + }, + { + slug: "solana-tx-landing", + name: "Solana tx landing market share", + ocbYaml: "benchmarks/solana-tx-landing.yml", + harness: RW("tx-landing-solana-bench", "tx-landing-solana-bench-production.up.railway.app"), + sourceUrl: `${OCB_HARNESS_BASE}/solana-tx-landing`, + promPrefix: "solana_tx_landing_", + }, + { + slug: "solana-tx-landing-latency", + name: "Solana tx landing latency (active probing)", + ocbYaml: "benchmarks/solana-tx-landing-latency.yml", + harness: RW("tx-landing-solana-bench", "tx-landing-solana-bench-production.up.railway.app"), + sourceUrl: `${OCB_HARNESS_BASE}/solana-tx-landing`, + promPrefix: "solana_landing_probe_", + }, + { + slug: "solana-dex-quote-latency", + name: "Fastest Solana DEX quote API", + ocbYaml: "benchmarks/solana-dex-quote-latency.yml", + harness: RW_MULTI( + { service: "solana-quote-us-east", host: "solana-quote-us-east-jd9g-cwjh-production.up.railway.app" }, + [ + { region: "eu-west", service: "solana-quote-eu-west", host: "solana-quote-eu-west-h888-za1j-production.up.railway.app" }, + { region: "sgp", service: "solana-quote-sgp", host: "solana-quote-sgp-i105-ytds-production.up.railway.app" }, + ], + ), + sourceUrl: `${OCB_HARNESS_BASE}/solana-quote-latency`, + promPrefix: "solana_dex_quote_", + }, + { + slug: "evm-quote-latency", + name: "Fastest EVM swap quote API", + ocbYaml: "benchmarks/evm-quote-latency.yml", + harness: { type: "none" }, + sourceUrl: `${OCB_HARNESS_BASE}/evm-swap-quoting`, + promPrefix: "evm_swap_quote_", + }, + { + slug: "aggregator-head-lag", + name: "Aggregator head lag", + ocbYaml: "benchmarks/aggregator-head-lag.yml", + harness: RW_MULTI( + { service: "aggregator-east-usa", host: "aggregator-east-usa-production.up.railway.app" }, + [ + { region: "eu-west", service: "agg-eu-west", host: "agg-eu-west-production.up.railway.app" }, + { region: "sgp", service: "agg-sgp", host: "agg-sgp-production.up.railway.app" }, + ], + ), + sourceUrl: `${OCB_HARNESS_BASE}/aggregator-head-lag`, + promPrefix: "agg_", + }, + { + slug: "bridge-fee", + name: "Bridge fee", + ocbYaml: "benchmarks/bridge-fee.yml", + harness: { type: "none" }, + sourceUrl: `${OCB_HARNESS_BASE}/bridge-monitor`, + promPrefix: "bridge_", + }, + { + slug: "bridge-quote-latency", + name: "Bridge quote latency", + ocbYaml: "benchmarks/bridge-quote-latency.yml", + harness: { type: "none" }, + sourceUrl: `${OCB_HARNESS_BASE}/bridge-monitor`, + promPrefix: "bridge_", + }, + { + slug: "bridge-revenue", + name: "Cross-chain bridge implied protocol revenue", + ocbYaml: "benchmarks/bridge-revenue.yml", + harness: { type: "none" }, + sourceUrl: `${OCB_HARNESS_BASE}/bridge-monitor`, + promPrefix: "bridge_", + }, + { + slug: "buyback-audit", + name: "Buyback audit", + ocbYaml: "benchmarks/buyback-audit.yml", + harness: RW("buyback-audit", "buyback-audit-production.up.railway.app"), + sourceUrl: `${OCB_HARNESS_BASE}/buyback-audit`, + }, + { + slug: "gas-estimation", + name: "Gas estimation", + ocbYaml: "benchmarks/gas-estimation.yml", + harness: RW("gas-fee-estimation", "gas-fee-estimation-production.up.railway.app"), + sourceUrl: `${OCB_HARNESS_BASE}/gas-estimation`, + }, + { + slug: "l1-finality", + name: "L1 finality", + ocbYaml: "benchmarks/l1-finality.yml", + harness: RW("l1-finality", "l1-finality-production.up.railway.app"), + sourceUrl: `${OCB_HARNESS_BASE}/l1-finality`, + promPrefix: "l1_finality_", + }, + { + slug: "l2-block-time", + name: "L2 block time", + ocbYaml: "benchmarks/l2-block-time.yml", + harness: RW("l2-block-time", "l2-block-time-production.up.railway.app"), + sourceUrl: `${OCB_HARNESS_BASE}/l2-block-time`, + }, + { + slug: "metadata-coverage", + name: "Metadata coverage", + ocbYaml: "benchmarks/metadata-coverage.yml", + harness: RW("metadata-coverage", "metadata-coverage-production.up.railway.app"), + sourceUrl: `${OCB_HARNESS_BASE}/metadata-coverage`, + }, + { + slug: "network-coverage", + name: "Network coverage", + ocbYaml: "benchmarks/network-coverage.yml", + harness: RW("network-coverage", "network-coverage-production-9eff.up.railway.app"), + sourceUrl: `${OCB_HARNESS_BASE}/network-coverage`, + }, + { + slug: "oracle-deviation", + name: "Oracle deviation", + ocbYaml: "benchmarks/oracle-deviation.yml", + harness: RW("oracle-deviation", "oracle-deviation-production.up.railway.app"), + sourceUrl: `${OCB_HARNESS_BASE}/oracle-deviation`, + }, + { + slug: "perp-fees", + name: "Perp fees", + ocbYaml: "benchmarks/perp-fees.yml", + harness: RW("perp-fee", "perp-fee-production.up.railway.app"), + sourceUrl: `${OCB_HARNESS_BASE}/perp-fees`, + }, + { + slug: "rpc-capabilities", + name: "RPC capabilities", + ocbYaml: "benchmarks/rpc-capabilities.yml", + harness: RW_MULTI( + { service: "rpc-capabilities-us", host: "rpc-capabilities-us-production.up.railway.app" }, + [ + { region: "eu", service: "rpc-capabilities-eu", host: "rpc-capabilities-eu-production.up.railway.app" }, + { region: "sgp", service: "rpc-capabilities-sgp", host: "rpc-capabilities-sgp-production.up.railway.app" }, + ], + ), + sourceUrl: `${OCB_HARNESS_BASE}/rpc-capabilities`, + }, + { + slug: "stablecoin-peg", + name: "Stablecoin peg", + ocbYaml: "benchmarks/stablecoin-peg.yml", + harness: RW("stablecoin-peg-bench", "stablecoin-peg-bench-production.up.railway.app"), + sourceUrl: `${OCB_HARNESS_BASE}/stablecoin-peg`, + }, + { + slug: "stablecoin-peg-usdt-anchored", + name: "Stablecoin peg (USDT anchored)", + ocbYaml: "benchmarks/stablecoin-peg-usdt-anchored.yml", + harness: RW("stablecoin-peg-bench", "stablecoin-peg-bench-production.up.railway.app"), + sourceUrl: `${OCB_HARNESS_BASE}/stablecoin-peg`, + }, + { + slug: "validator-yield", + name: "Validator yield", + ocbYaml: "benchmarks/validator-yield.yml", + harness: RW("validator-yield", "validator-yield-production.up.railway.app"), + sourceUrl: `${OCB_HARNESS_BASE}/validator-yield`, + }, + { + slug: "wallet-labels-coverage", + name: "Wallet labels coverage", + ocbYaml: "benchmarks/wallet-labels-coverage.yml", + harness: RW("wallet-labels", "mobula-monorepo-production-1f1b.up.railway.app"), + sourceUrl: `${OCB_HARNESS_BASE}/wallet-labels`, + }, + { + slug: "pm-data-freshness", + name: "Best prediction market data API by freshness", + ocbYaml: "benchmarks/pm-data-freshness.yml", + harness: RW("pm-freshness-bench", "pm-freshness-bench-production.up.railway.app"), + sourceUrl: `${MOBULA_BASE}/pm-freshness-bench`, + promPrefix: "pm_freshness_", + }, +]; diff --git a/infrastructure/monitoring-ui/src/middleware.ts b/infrastructure/monitoring-ui/src/middleware.ts new file mode 100644 index 00000000..05842d3d --- /dev/null +++ b/infrastructure/monitoring-ui/src/middleware.ts @@ -0,0 +1,25 @@ +import { NextResponse, type NextRequest } from "next/server"; + +const REALM = 'Basic realm="OpenBench monitoring", charset="UTF-8"'; + +export function middleware(req: NextRequest) { + const expected = process.env.ADMIN_BASIC_AUTH; + if (!expected) return NextResponse.next(); + + const header = req.headers.get("authorization"); + if (header) { + const [scheme, value] = header.split(" "); + if (scheme === "Basic" && value) { + const decoded = Buffer.from(value, "base64").toString("utf-8"); + if (decoded === expected) return NextResponse.next(); + } + } + return new NextResponse("Unauthorized", { + status: 401, + headers: { "WWW-Authenticate": REALM }, + }); +} + +export const config = { + matcher: ["/((?!_next/static|_next/image|favicon.ico|healthz).*)"], +}; diff --git a/infrastructure/monitoring-ui/tailwind.config.ts b/infrastructure/monitoring-ui/tailwind.config.ts new file mode 100644 index 00000000..b269d72c --- /dev/null +++ b/infrastructure/monitoring-ui/tailwind.config.ts @@ -0,0 +1,9 @@ +import type { Config } from "tailwindcss"; + +const config: Config = { + content: ["./src/**/*.{ts,tsx}"], + theme: { extend: {} }, + plugins: [], +}; + +export default config; diff --git a/infrastructure/monitoring-ui/tsconfig.json b/infrastructure/monitoring-ui/tsconfig.json new file mode 100644 index 00000000..334fafd8 --- /dev/null +++ b/infrastructure/monitoring-ui/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./src/*"] } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/infrastructure/monitoring/.env.example b/infrastructure/monitoring/.env.example new file mode 100644 index 00000000..5f0abe85 --- /dev/null +++ b/infrastructure/monitoring/.env.example @@ -0,0 +1,31 @@ +# Runtime env vars for the monitoring stack. Each subdir is its own Railway +# service; set the vars on the matching service. NEVER commit real values. + +# --- prometheus/ --- +# external_labels.environment (expanded via --enable-feature=expand-external-labels). +# Leave unset for prod; "staging" activates the [STAGING] alert prefix. +ENVIRONMENT= +# Basic-auth password for the hyperliquid-frontends-local-v2 scrape (Caddy on +# the OVH host). Written to /etc/prometheus/secrets/hl_bench_local_auth by +# entrypoint.sh; prometheus.yml references it via password_file. +HL_BENCH_LOCAL_AUTH= + +# --- alertmanager/ --- +# Webhook receiver for alert payloads (Slack relay). Substituted into +# alertmanager.yml.tmpl at container start by entrypoint.sh. +ALERT_WEBHOOK_URL= + +# --- grafana/ --- +# Grafana reads GF_* env vars natively; no file substitution needed. +GF_SECURITY_ADMIN_USER= +GF_SECURITY_ADMIN_PASSWORD= +# Prometheus datasource URL (defaults to prometheus.railway.internal:9090). +PROMETHEUS_URL= +# Optional: hide the quote-latency dashboard on staging. +HIDE_QUOTE_DASHBOARD=false + +# --- prom-gateway/ --- +# Long random secret gating the Prom admin/lifecycle endpoints (X-Admin-Token). +PROM_ADMIN_TOKEN= +# Internal DNS of the Prom service, e.g. prometheus.railway.internal:9090 +PROM_UPSTREAM= diff --git a/infrastructure/monitoring/README.md b/infrastructure/monitoring/README.md new file mode 100644 index 00000000..150db339 --- /dev/null +++ b/infrastructure/monitoring/README.md @@ -0,0 +1,53 @@ +# monitoring + +Shared monitoring stack for OpenChainBench. Consumed by every benchmark (`aggregator-head-lag`, `metadata-coverage`, `bridge-monitor`, etc.) — not by any single one. + +## Layout + +``` +prometheus/ Prometheus server — scrapes every harness service in this Railway project. + The site openchainbench.com queries this Prom (through prom-gateway) via HTTP API. +grafana/ Grafana dashboards for internal ops use. Reads the Prometheus above. +alertmanager/ Receives alerts from Prometheus rules, routes to the ops webhook. +prom-gateway/ Caddy reverse proxy — the single public entrypoint in front of Prometheus. +``` + +Each subfolder is an independent Railway service deployed from its own Dockerfile (set the Railway **Root Directory** to `infrastructure/monitoring/`). + +## Service mapping (Railway) + +| Folder | Railway service | Exposure | +| --- | --- | --- | +| `prometheus/` | Prometheus | internal only — fronted by `prom-gateway` | +| `prom-gateway/` | prom-gateway | public read API, token-gated admin API | +| `grafana/` | Grafana | internal ops dashboards | +| `alertmanager/` | alertmanager | internal alert routing → ops webhook | + +## Secrets + +No secret lives in this directory. Every credential is injected at runtime via Railway env vars — see `.env.example` for the full list and which service each var belongs to: + +- `HL_BENCH_LOCAL_AUTH` (prometheus) — materialised to a `password_file` by `prometheus/entrypoint.sh` because Prom 2.x does not expand env vars in scrape configs. +- `ALERT_WEBHOOK_URL` (alertmanager) — substituted into `alertmanager.yml.tmpl` at container start by `alertmanager/entrypoint.sh`. +- `GF_SECURITY_ADMIN_USER` / `GF_SECURITY_ADMIN_PASSWORD` (grafana) — read natively by Grafana. +- `PROM_ADMIN_TOKEN` / `PROM_UPSTREAM` (prom-gateway) — read by the Caddyfile via `{env.*}`. + +## Adding a new scrape target + +When a new harness service is deployed (e.g. for a new OpenChainBench benchmark), append a job to `prometheus/prometheus.yml` : + +```yaml + - job_name: '' + static_configs: + - targets: + - '.railway.internal:' + labels: + benchmark: + metrics_path: /metrics +``` + +Then redeploy the Prometheus service. The new metrics start flowing into the same Prom and become queryable from the site. + +## Migration note + +This stack previously built from the private Mobula monorepo (`miniapps/openchainbench-monitoring/`). It was ported here on 2026-07-03 so the public OCB repo is the canonical build source for every Railway service; all inline credentials were externalised to env vars in the process. diff --git a/infrastructure/monitoring/alertmanager/Dockerfile b/infrastructure/monitoring/alertmanager/Dockerfile new file mode 100644 index 00000000..bba1f8e9 --- /dev/null +++ b/infrastructure/monitoring/alertmanager/Dockerfile @@ -0,0 +1,16 @@ +FROM prom/alertmanager:latest + +USER root + +# The webhook receiver URL is injected at container start from the +# ALERT_WEBHOOK_URL env var (set on the Railway service) — never baked into +# the image. entrypoint.sh substitutes the placeholder with plain sed +# (busybox-safe, no envsubst dependency). +COPY alertmanager.yml.tmpl /etc/alertmanager/alertmanager.yml.tmpl +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# Expose AlertManager port +EXPOSE 9093 + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/infrastructure/monitoring/alertmanager/alertmanager.yml.tmpl b/infrastructure/monitoring/alertmanager/alertmanager.yml.tmpl new file mode 100644 index 00000000..49d6d7ab --- /dev/null +++ b/infrastructure/monitoring/alertmanager/alertmanager.yml.tmpl @@ -0,0 +1,36 @@ +global: + resolve_timeout: 5m + +route: + group_by: ['alertname', 'chain', 'aggregator'] + group_wait: 10s + group_interval: 30s + repeat_interval: 5m + receiver: 'slack-webhook' + +receivers: + - name: 'slack-webhook' + webhook_configs: + - url: '${ALERT_WEBHOOK_URL}' + send_resolved: true + +inhibit_rules: + # Inhibit warning alerts if critical alert is firing + - source_match: + severity: 'critical' + target_match: + severity: 'warning' + equal: ['alertname', 'chain', 'aggregator'] + + # Inhibit stale metrics alerts if service is down + - source_match: + alert_type: 'service_down' + target_match: + alert_type: 'stale_metrics' + + # Inhibit stale metrics if missing metrics alert is firing + - source_match: + alert_type: 'missing_metrics' + target_match: + alert_type: 'stale_metrics' + equal: ['aggregator'] diff --git a/infrastructure/monitoring/alertmanager/entrypoint.sh b/infrastructure/monitoring/alertmanager/entrypoint.sh new file mode 100755 index 00000000..2c149d01 --- /dev/null +++ b/infrastructure/monitoring/alertmanager/entrypoint.sh @@ -0,0 +1,22 @@ +#!/bin/sh +set -e + +# Alertmanager does not expand env vars in its YAML by itself. Substitute the +# ALERT_WEBHOOK_URL placeholder at container start using sed (busybox-safe — +# no envsubst dependency). The webhook URL contains only /, alphanumerics and +# colons so | as the sed delimiter is safe. +# +# ALERT_WEBHOOK_URL: set on the Railway alertmanager service. Points at the +# internal Slack relay that forwards alert payloads to the ops channel. +if [ -z "$ALERT_WEBHOOK_URL" ]; then + echo "WARNING: ALERT_WEBHOOK_URL not set — alerts will be dropped by the webhook receiver." +fi + +sed "s|\${ALERT_WEBHOOK_URL}|${ALERT_WEBHOOK_URL}|g" \ + /etc/alertmanager/alertmanager.yml.tmpl \ + > /etc/alertmanager/alertmanager.yml + +exec /bin/alertmanager \ + --config.file=/etc/alertmanager/alertmanager.yml \ + --storage.path=/alertmanager \ + --log.level=debug diff --git a/infrastructure/monitoring/grafana/Dockerfile b/infrastructure/monitoring/grafana/Dockerfile new file mode 100644 index 00000000..a7a9de0a --- /dev/null +++ b/infrastructure/monitoring/grafana/Dockerfile @@ -0,0 +1,36 @@ +FROM grafana/grafana:latest + +USER root + +# Cache buster - update this to force rebuild: v7 +ARG CACHE_BUST=7 + +# Copy provisioning configs and dashboards source (we're in grafana folder) +COPY provisioning /etc/grafana/provisioning +COPY dashboards /dashboards-source + +# Copy entrypoint script +COPY grafana-entrypoint.sh /grafana-entrypoint.sh +RUN chmod +x /grafana-entrypoint.sh + +# Set permissions +RUN chown -R grafana:root /etc/grafana/provisioning /dashboards-source + +USER grafana + +# Environment variables. +# Admin credentials are NOT baked into the image: set GF_SECURITY_ADMIN_USER +# and GF_SECURITY_ADMIN_PASSWORD on the Railway service (Grafana reads GF_* +# env vars natively). Without them Grafana falls back to its stock +# admin/admin bootstrap and forces a password change on first login. +ENV GF_AUTH_ANONYMOUS_ENABLED=true +ENV GF_AUTH_ANONYMOUS_ORG_ROLE=Viewer +ENV GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH=/var/lib/grafana/dashboards/head_lag.json + +# Prometheus datasource URL (overridden by staging deployment) +ENV PROMETHEUS_URL=http://prometheus.railway.internal:9090 + +EXPOSE 3000 + +# Use custom entrypoint +ENTRYPOINT ["/bin/sh", "/grafana-entrypoint.sh"] diff --git a/infrastructure/monitoring/grafana/dashboards/disabled/mobula_pulse_vs_fasttrade.json b/infrastructure/monitoring/grafana/dashboards/disabled/mobula_pulse_vs_fasttrade.json new file mode 100644 index 00000000..373e2f06 --- /dev/null +++ b/infrastructure/monitoring/grafana/dashboards/disabled/mobula_pulse_vs_fasttrade.json @@ -0,0 +1,595 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Latency (ms)", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 2000 + }, + { + "color": "orange", + "value": 5000 + }, + { + "color": "red", + "value": 10000 + } + ] + }, + "unit": "ms" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*Pulse.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "blue", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*Fast-Trade.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "orange", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": ["mean", "last", "max", "min"], + "displayMode": "table", + "placement": "right", + "showLegend": true, + "sortBy": "Mean", + "sortDesc": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "pool_discovery_latency_milliseconds{aggregator=\"mobula\"}", + "legendFormat": "[{{region}}] Pulse V2 - {{chain}}", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "fast_trade_latency_milliseconds{aggregator=\"mobula\"}", + "legendFormat": "[{{region}}] Fast-Trade - {{chain}}", + "range": true, + "refId": "B" + } + ], + "title": "Mobula: Pulse V2 (Discovery) vs Fast-Trade (Swap Indexation)", + "description": "Pulse V2 measures pool discovery latency (on-chain creation → Mobula indexation). Fast-Trade measures swap indexation latency (on-chain swap → WebSocket receipt).", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 2000 + }, + { + "color": "orange", + "value": 5000 + }, + { + "color": "red", + "value": 10000 + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 10 + }, + "id": 3, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": ["last"], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "fast_trade_latency_milliseconds{aggregator=\"mobula\"}", + "legendFormat": "[{{region}}] {{chain}}", + "range": true, + "refId": "A" + } + ], + "title": "Fast-Trade - Current Swap Indexation Latency", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Latency (ms)", + "axisPlacement": "auto", + "drawStyle": "line", + "fillOpacity": 10, + "lineInterpolation": "linear", + "lineWidth": 2, + "showPoints": "auto" + }, + "unit": "ms", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 2000 + }, + { + "color": "red", + "value": 5000 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 10 + }, + "id": 4, + "options": { + "legend": { + "calcs": ["last"], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "pool_discovery_latency_milliseconds{aggregator=\"mobula\",chain=\"solana\"}", + "legendFormat": "[{{region}}] Pulse V2", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "fast_trade_latency_milliseconds{aggregator=\"mobula\",chain=\"solana\"}", + "legendFormat": "[{{region}}] Fast-Trade", + "refId": "B" + } + ], + "title": "Solana - Pulse V2 vs Fast-Trade", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Latency (ms)", + "axisPlacement": "auto", + "drawStyle": "line", + "fillOpacity": 10, + "lineInterpolation": "linear", + "lineWidth": 2, + "showPoints": "auto" + }, + "unit": "ms", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 2000 + }, + { + "color": "red", + "value": 5000 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 10 + }, + "id": 5, + "options": { + "legend": { + "calcs": ["last"], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "pool_discovery_latency_milliseconds{aggregator=\"mobula\",chain=\"base\"}", + "legendFormat": "[{{region}}] Pulse V2", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "fast_trade_latency_milliseconds{aggregator=\"mobula\",chain=\"base\"}", + "legendFormat": "[{{region}}] Fast-Trade", + "refId": "B" + } + ], + "title": "Base - Pulse V2 vs Fast-Trade", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Latency (ms)", + "axisPlacement": "auto", + "drawStyle": "line", + "fillOpacity": 10, + "lineInterpolation": "linear", + "lineWidth": 2, + "showPoints": "auto" + }, + "unit": "ms", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 2000 + }, + { + "color": "red", + "value": 5000 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 10 + }, + "id": 6, + "options": { + "legend": { + "calcs": ["last"], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "pool_discovery_latency_milliseconds{aggregator=\"mobula\",chain=\"ethereum\"}", + "legendFormat": "[{{region}}] Pulse V2", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "fast_trade_latency_milliseconds{aggregator=\"mobula\",chain=\"ethereum\"}", + "legendFormat": "[{{region}}] Fast-Trade", + "refId": "B" + } + ], + "title": "Ethereum - Pulse V2 vs Fast-Trade", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Latency (ms)", + "axisPlacement": "auto", + "drawStyle": "line", + "fillOpacity": 10, + "lineInterpolation": "linear", + "lineWidth": 2, + "showPoints": "auto" + }, + "unit": "ms", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 2000 + }, + { + "color": "red", + "value": 5000 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 18 + }, + "id": 7, + "options": { + "legend": { + "calcs": ["last"], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "pool_discovery_latency_milliseconds{aggregator=\"mobula\",chain=\"bnb\"}", + "legendFormat": "[{{region}}] Pulse V2", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "fast_trade_latency_milliseconds{aggregator=\"mobula\",chain=\"bnb\"}", + "legendFormat": "[{{region}}] Fast-Trade", + "refId": "B" + } + ], + "title": "BNB - Pulse V2 vs Fast-Trade", + "type": "timeseries" + } + ], + "refresh": "30s", + "schemaVersion": 39, + "tags": ["mobula", "pulse", "fast-trade", "comparison"], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Mobula: Pulse V2 vs Fast-Trade Comparison", + "uid": "mobula_pulse_vs_fasttrade", + "version": 0, + "weekStart": "" +} diff --git a/infrastructure/monitoring/grafana/dashboards/disabled/pulse_vs_codex.json b/infrastructure/monitoring/grafana/dashboards/disabled/pulse_vs_codex.json new file mode 100644 index 00000000..8ced6186 --- /dev/null +++ b/infrastructure/monitoring/grafana/dashboards/disabled/pulse_vs_codex.json @@ -0,0 +1,661 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Direct comparison of Pulse vs Codex head lag on monitored pools", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Latency (seconds)", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 5 + } + ] + }, + "unit": "s" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*pulse.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "blue", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*codex.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": ["lastNotNull", "mean", "max"], + "displayMode": "table", + "placement": "right", + "showLegend": true, + "sortBy": "Mean", + "sortDesc": false + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "head_lag_seconds{aggregator=~\"pulse|codex\",chain=~\"$chain\"}", + "legendFormat": "{{chain}} | {{aggregator}}", + "range": true, + "refId": "A" + } + ], + "title": "Pulse vs Codex - Head Lag Comparison", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Shows which provider is faster (negative = Pulse faster, positive = Codex faster)", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": true, + "axisColorMode": "text", + "axisLabel": "Delta (seconds)", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 30, + "gradientMode": "hue", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "area" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": -1 + }, + { + "color": "red", + "value": 1 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 24, + "x": 0, + "y": 10 + }, + "id": 2, + "options": { + "legend": { + "calcs": ["mean", "lastNotNull"], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "(head_lag_seconds{aggregator=\"pulse\",chain=~\"$chain\"} - ignoring(aggregator) head_lag_seconds{aggregator=\"codex\",chain=~\"$chain\"})", + "legendFormat": "{{chain}} - Pulse vs Codex delta", + "range": true, + "refId": "A" + } + ], + "title": "Latency Delta: Pulse - Codex (negative = Pulse faster)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "color-text" + }, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 1 + }, + { + "color": "orange", + "value": 2 + }, + { + "color": "red", + "value": 5 + } + ] + }, + "unit": "s" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Chain" + }, + "properties": [ + { + "id": "custom.width", + "value": 120 + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Provider" + }, + "properties": [ + { + "id": "custom.width", + "value": 100 + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 19 + }, + "id": 3, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": ["sum"], + "show": false + }, + "showHeader": true, + "sortBy": [ + { + "desc": false, + "displayName": "Mean (5m)" + } + ] + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "exemplar": false, + "expr": "head_lag_seconds{aggregator=~\"pulse|codex\",chain=~\"$chain\"}", + "format": "table", + "instant": true, + "legendFormat": "__auto", + "range": false, + "refId": "Current" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "exemplar": false, + "expr": "avg_over_time(head_lag_seconds{aggregator=~\"pulse|codex\",chain=~\"$chain\"}[5m])", + "format": "table", + "hide": false, + "instant": true, + "legendFormat": "__auto", + "range": false, + "refId": "Mean5m" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "exemplar": false, + "expr": "max_over_time(head_lag_seconds{aggregator=~\"pulse|codex\",chain=~\"$chain\"}[5m])", + "format": "table", + "hide": false, + "instant": true, + "legendFormat": "__auto", + "range": false, + "refId": "Max5m" + } + ], + "title": "Current Stats - Pulse vs Codex", + "transformations": [ + { + "id": "merge", + "options": {} + }, + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true, + "__name__": true, + "deployment": true, + "instance": true, + "job": true, + "region": true + }, + "includeByName": {}, + "indexByName": {}, + "renameByName": { + "aggregator": "Provider", + "chain": "Chain" + } + } + }, + { + "id": "groupBy", + "options": { + "fields": { + "Chain": { + "aggregations": [], + "operation": "groupby" + }, + "Provider": { + "aggregations": [], + "operation": "groupby" + }, + "Value #Current": { + "aggregations": ["lastNotNull"], + "operation": "aggregate" + }, + "Value #Max5m": { + "aggregations": ["lastNotNull"], + "operation": "aggregate" + }, + "Value #Mean5m": { + "aggregations": ["lastNotNull"], + "operation": "aggregate" + } + } + } + }, + { + "id": "organize", + "options": { + "excludeByName": {}, + "includeByName": {}, + "indexByName": { + "Chain": 0, + "Provider": 1, + "Value #Current (lastNotNull)": 2, + "Value #Max5m (lastNotNull)": 4, + "Value #Mean5m (lastNotNull)": 3 + }, + "renameByName": { + "Value #Current (lastNotNull)": "Current", + "Value #Max5m (lastNotNull)": "Max (5m)", + "Value #Mean5m (lastNotNull)": "Mean (5m)" + } + } + } + ], + "type": "table" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 1 + }, + { + "color": "red", + "value": 3 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 0, + "y": 27 + }, + "id": 4, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "head_lag_seconds{aggregator=\"pulse\",chain=~\"$chain\"}", + "instant": true, + "legendFormat": "{{chain}}", + "refId": "A" + } + ], + "title": "Pulse - Current Head Lag", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 1 + }, + { + "color": "red", + "value": 3 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 12, + "x": 12, + "y": 27 + }, + "id": 5, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "head_lag_seconds{aggregator=\"codex\",chain=~\"$chain\"}", + "instant": true, + "legendFormat": "{{chain}}", + "refId": "A" + } + ], + "title": "Codex - Current Head Lag", + "type": "gauge" + } + ], + "refresh": "30s", + "schemaVersion": 39, + "tags": ["pulse", "codex", "comparison", "head-lag"], + "templating": { + "list": [ + { + "current": { + "selected": true, + "text": ["All"], + "value": ["$__all"] + }, + "hide": 0, + "includeAll": true, + "label": "Chain", + "multi": true, + "name": "chain", + "options": [ + { + "selected": true, + "text": "All", + "value": "$__all" + }, + { + "selected": false, + "text": "solana", + "value": "solana" + }, + { + "selected": false, + "text": "base", + "value": "base" + }, + { + "selected": false, + "text": "bnb", + "value": "bnb" + } + ], + "query": "solana,base,bnb", + "queryValue": "", + "skipUrlSync": false, + "type": "custom" + } + ] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Pulse vs Codex - Head Lag Comparison", + "uid": "pulse_vs_codex", + "version": 1, + "weekStart": "" +} diff --git a/infrastructure/monitoring/grafana/dashboards/disabled/quote_api_latency.json b/infrastructure/monitoring/grafana/dashboards/disabled/quote_api_latency.json new file mode 100644 index 00000000..d0e12537 --- /dev/null +++ b/infrastructure/monitoring/grafana/dashboards/disabled/quote_api_latency.json @@ -0,0 +1,916 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 3, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 500 + }, + { + "color": "red", + "value": 2000 + } + ] + }, + "unit": "ms" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "orange", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*jupiter.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*kyberswap.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "blue", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*lifi.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "purple", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*paraswap.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "yellow", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*openocean.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": ["mean", "lastNotNull", "max", "min"], + "displayMode": "table", + "placement": "right", + "showLegend": true, + "sortBy": "Mean", + "sortDesc": false + }, + "tooltip": { + "mode": "multi", + "sort": "asc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.50, sum(rate(quote_api_latency_milliseconds_bucket{chain!=\"arbitrum\"}[1m])) by (le, provider, chain))", + "legendFormat": "{{provider}} - {{chain}} (P50)", + "range": true, + "refId": "A" + } + ], + "title": "Quote API Latency Comparison - All Providers (P50)", + "description": "Median Quote API response time - Mobula vs competitors (30s polling interval)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 300 + }, + { + "color": "orange", + "value": 700 + }, + { + "color": "red", + "value": 1500 + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 24, + "x": 0, + "y": 10 + }, + "id": 2, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.50, sum(rate(quote_api_latency_milliseconds_bucket[5m])) by (le, provider))", + "legendFormat": "{{provider}}", + "range": true, + "refId": "A" + } + ], + "title": "Latest Quote API Latency (P50) by Provider", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 3, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 1000 + } + ] + }, + "unit": "ms" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "orange", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*jupiter.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 3, + "options": { + "legend": { + "calcs": ["mean", "lastNotNull", "max"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "asc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.50, sum(rate(quote_api_latency_milliseconds_bucket{chain=\"solana\"}[1m])) by (le, provider))", + "legendFormat": "{{provider}} (P50)", + "range": true, + "refId": "A" + } + ], + "title": "🌞 Solana Quote APIs - Mobula vs Jupiter", + "description": "Solana swap quote latency comparison", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 3, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 1000 + } + ] + }, + "unit": "ms" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "orange", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 16 + }, + "id": 4, + "options": { + "legend": { + "calcs": ["mean", "lastNotNull", "max"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "asc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.50, sum(rate(quote_api_latency_milliseconds_bucket{chain=\"base\"}[1m])) by (le, provider))", + "legendFormat": "{{provider}} (P50)", + "range": true, + "refId": "A" + } + ], + "title": "🔵 Base Quote APIs - Mobula vs Competitors", + "description": "Base chain swap quote latency comparison", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 3, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 1000 + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 24 + }, + "id": 6, + "options": { + "legend": { + "calcs": ["mean", "lastNotNull", "max"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "asc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.50, sum(rate(quote_api_latency_milliseconds_bucket{chain=\"ethereum\"}[1m])) by (le, provider))", + "legendFormat": "{{provider}} (P50)", + "range": true, + "refId": "A" + } + ], + "title": "⟠ Ethereum Quote APIs - Competitors Only", + "description": "Ethereum chain swap quote latency (Mobula not deployed)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "bars", + "fillOpacity": 80, + "gradientMode": "hue", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 12, + "x": 0, + "y": 32 + }, + "id": 7, + "options": { + "legend": { + "calcs": ["sum"], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(increase(quote_api_errors_total{chain!=\"arbitrum\"}[5m])) by (provider, chain)", + "legendFormat": "{{provider}} - {{chain}}", + "range": true, + "refId": "A" + } + ], + "title": "Quote API Errors (Last 5min)", + "description": "Number of errors per provider and chain", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 95 + }, + { + "color": "red", + "value": 99 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 12, + "x": 12, + "y": 32 + }, + "id": 8, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "100 * sum(rate(quote_api_status_codes_total{status_code=\"200\"}[5m])) by (provider) / sum(rate(quote_api_status_codes_total[5m])) by (provider)", + "legendFormat": "{{provider}}", + "range": true, + "refId": "A" + } + ], + "title": "Quote API Success Rate by Provider", + "description": "Percentage of successful (200) responses", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "ms" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 38 + }, + "id": 9, + "options": { + "legend": { + "calcs": ["mean", "lastNotNull", "max"], + "displayMode": "table", + "placement": "right", + "showLegend": true, + "sortBy": "Mean", + "sortDesc": false + }, + "tooltip": { + "mode": "multi", + "sort": "asc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.50, sum(rate(quote_api_latency_milliseconds_bucket{provider=\"mobula\",chain!=\"arbitrum\"}[1m])) by (le, chain))", + "legendFormat": "Mobula - {{chain}} (P50)", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.95, sum(rate(quote_api_latency_milliseconds_bucket{provider=\"mobula\",chain!=\"arbitrum\"}[1m])) by (le, chain))", + "legendFormat": "Mobula - {{chain}} (P95)", + "range": true, + "refId": "B" + } + ], + "title": "Mobula Quote API Latency by Chain (P50 & P95)", + "description": "Mobula swap quote latency across supported chains", + "type": "timeseries" + } + ], + "refresh": "5s", + "schemaVersion": 39, + "tags": ["quote-api", "swap", "latency", "mobula", "jupiter", "benchmark"], + "templating": { + "list": [] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Quote API Latency Benchmark", + "uid": "quote_api_latency", + "version": 0, + "weekStart": "" +} diff --git a/infrastructure/monitoring/grafana/dashboards/head_lag.json b/infrastructure/monitoring/grafana/dashboards/head_lag.json new file mode 100644 index 00000000..89a6f45a --- /dev/null +++ b/infrastructure/monitoring/grafana/dashboards/head_lag.json @@ -0,0 +1,945 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Seconds Behind", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 30 + }, + { + "color": "orange", + "value": 60 + }, + { + "color": "red", + "value": 300 + } + ] + }, + "unit": "s" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#1F78B4", + "mode": "fixed" + } + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "#1F78B4", + "value": null + } + ] + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*codex.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#F4D03F", + "mode": "fixed" + } + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "#F4D03F", + "value": null + } + ] + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*geckoterminal.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#27AE60", + "mode": "fixed" + } + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "#27AE60", + "value": null + } + ] + } + } + ] + } + ] + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "calcs": [ + "mean", + "last", + "max", + "min" + ], + "displayMode": "table", + "placement": "right", + "showLegend": true, + "sortBy": "Mean", + "sortDesc": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "avg_over_time(head_lag_seconds{aggregator!=\"gmgn\"}[1m])", + "legendFormat": "[{{region}}] {{aggregator}} - {{chain}}", + "range": true, + "refId": "A" + } + ], + "title": "Head Lag (Estimated Seconds Behind)", + "description": "Estimated time in seconds the aggregator is behind the blockchain head. Calculated using average block time per chain.", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "fixed", + "fixedColor": "#1F78B4" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 5 + }, + { + "color": "orange", + "value": 10 + }, + { + "color": "red", + "value": 30 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 18 + }, + "id": 3, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "avg_over_time(head_lag_seconds{aggregator=\"mobula\"}[1m])", + "legendFormat": "[{{region}}] {{chain}}", + "range": true, + "refId": "A" + } + ], + "title": "Mobula - Current Head Lag (Seconds)", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "fixed", + "fixedColor": "#F4D03F" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 5 + }, + { + "color": "orange", + "value": 10 + }, + { + "color": "red", + "value": 30 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 18 + }, + "id": 4, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "avg_over_time(head_lag_seconds{aggregator=\"codex\"}[1m])", + "legendFormat": "[{{region}}] {{chain}}", + "range": true, + "refId": "A" + } + ], + "title": "Codex - Current Head Lag (Seconds)", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "fixed", + "fixedColor": "#27AE60" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 5 + }, + { + "color": "orange", + "value": 10 + }, + { + "color": "red", + "value": 30 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 18 + }, + "id": 5, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "11.0.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "avg_over_time(head_lag_seconds{aggregator=\"geckoterminal\"}[1m])", + "legendFormat": "[{{region}}] {{chain}}", + "range": true, + "refId": "A" + } + ], + "title": "GeckoTerminal - Current Head Lag (Seconds)", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Seconds", + "axisPlacement": "auto", + "drawStyle": "line", + "fillOpacity": 0, + "lineInterpolation": "linear", + "lineWidth": 2, + "showPoints": "auto", + "spanNulls": true + }, + "unit": "s", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 5 + }, + { + "color": "red", + "value": 20 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#1F78B4", + "mode": "fixed" + } + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "#1F78B4", + "value": null + } + ] + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*codex.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#F4D03F", + "mode": "fixed" + } + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "#F4D03F", + "value": null + } + ] + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*geckoterminal.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#27AE60", + "mode": "fixed" + } + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "#27AE60", + "value": null + } + ] + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 0, + "y": 10 + }, + "id": 6, + "options": { + "legend": { + "calcs": [ + "lastNotNull" + ], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "avg_over_time(head_lag_seconds{region=\"eu-west\", aggregator!=\"gmgn\"}[1m])", + "legendFormat": "[{{region}}] {{aggregator}} - {{chain}}", + "refId": "A" + } + ], + "title": "EU West - Head Lag", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Seconds", + "axisPlacement": "auto", + "drawStyle": "line", + "fillOpacity": 0, + "lineInterpolation": "linear", + "lineWidth": 2, + "showPoints": "auto", + "spanNulls": true + }, + "unit": "s", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 5 + }, + { + "color": "red", + "value": 20 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#1F78B4", + "mode": "fixed" + } + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "#1F78B4", + "value": null + } + ] + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*codex.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#F4D03F", + "mode": "fixed" + } + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "#F4D03F", + "value": null + } + ] + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*geckoterminal.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#27AE60", + "mode": "fixed" + } + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "#27AE60", + "value": null + } + ] + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 8, + "y": 10 + }, + "id": 7, + "options": { + "legend": { + "calcs": [ + "lastNotNull" + ], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "avg_over_time(head_lag_seconds{region=\"us-east\", aggregator!=\"gmgn\"}[1m])", + "legendFormat": "[{{region}}] {{aggregator}} - {{chain}}", + "refId": "A" + } + ], + "title": "US East - Head Lag", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Seconds", + "axisPlacement": "auto", + "drawStyle": "line", + "fillOpacity": 0, + "lineInterpolation": "linear", + "lineWidth": 2, + "showPoints": "auto", + "spanNulls": true + }, + "unit": "s", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 5 + }, + { + "color": "red", + "value": 20 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#1F78B4", + "mode": "fixed" + } + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "#1F78B4", + "value": null + } + ] + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*codex.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#F4D03F", + "mode": "fixed" + } + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "#F4D03F", + "value": null + } + ] + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*geckoterminal.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#27AE60", + "mode": "fixed" + } + }, + { + "id": "thresholds", + "value": { + "mode": "absolute", + "steps": [ + { + "color": "#27AE60", + "value": null + } + ] + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 8, + "x": 16, + "y": 10 + }, + "id": 8, + "options": { + "legend": { + "calcs": [ + "lastNotNull" + ], + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "avg_over_time(head_lag_seconds{region=\"sgp\", aggregator!=\"gmgn\"}[1m])", + "legendFormat": "[{{region}}] {{aggregator}} - {{chain}}", + "refId": "A" + } + ], + "title": "Singapore - Head Lag", + "type": "timeseries" + } + ], + "refresh": "30s", + "schemaVersion": 39, + "tags": [ + "head-lag", + "indexation", + "blockchain", + "sync" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Head Lag Monitor - Blockchain vs Aggregator Sync", + "uid": "head_lag_monitor", + "version": 0, + "weekStart": "" +} diff --git a/infrastructure/monitoring/grafana/dashboards/metadata_coverage.json b/infrastructure/monitoring/grafana/dashboards/metadata_coverage.json new file mode 100644 index 00000000..b4c7b305 --- /dev/null +++ b/infrastructure/monitoring/grafana/dashboards/metadata_coverage.json @@ -0,0 +1,1133 @@ +{ + "annotations": { + "list": [] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "percent" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#1F78B4", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*codex.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#F4D03F", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*jupiter.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#27AE60", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "barRadius": 0.1, + "barWidth": 0.8, + "fullHighlight": false, + "groupWidth": 0.7, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "right", + "showLegend": true + }, + "orientation": "horizontal", + "showValue": "always", + "stacking": "none", + "tooltip": { + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 0 + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "exemplar": false, + "expr": "(sum(metadata_coverage_success_total{field=\"logo\"}) by (provider) / sum(metadata_coverage_checks_total{field=\"logo\"}) by (provider)) * 100", + "format": "time_series", + "instant": true, + "legendFormat": "{{provider}} - Logo", + "range": false, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "exemplar": false, + "expr": "(sum(metadata_coverage_success_total{field=\"description\"}) by (provider) / sum(metadata_coverage_checks_total{field=\"description\"}) by (provider)) * 100", + "format": "time_series", + "instant": true, + "legendFormat": "{{provider}} - Description", + "range": false, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "exemplar": false, + "expr": "(sum(metadata_coverage_success_total{field=\"twitter\"}) by (provider) / sum(metadata_coverage_checks_total{field=\"twitter\"}) by (provider)) * 100", + "format": "time_series", + "instant": true, + "legendFormat": "{{provider}} - Twitter", + "range": false, + "refId": "C" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "exemplar": false, + "expr": "(sum(metadata_coverage_success_total{field=\"website\"}) by (provider) / sum(metadata_coverage_checks_total{field=\"website\"}) by (provider)) * 100", + "format": "time_series", + "instant": true, + "legendFormat": "{{provider}} - Website", + "range": false, + "refId": "D" + } + ], + "title": "Metadata Coverage Comparison: Mobula vs Codex vs Jupiter (%)", + "description": "Percentage of new tokens with each metadata field present", + "type": "barchart" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "yellow", + "value": 30 + }, + { + "color": "green", + "value": 70 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 4, + "x": 0, + "y": 8 + }, + "id": 2, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "(sum(metadata_coverage_success_total{provider=\"mobula\", field=\"logo\"}) / sum(metadata_coverage_checks_total{provider=\"mobula\", field=\"logo\"})) * 100", + "instant": true, + "legendFormat": "Logo", + "refId": "A" + } + ], + "title": "Mobula - Logo %", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "yellow", + "value": 30 + }, + { + "color": "green", + "value": 70 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 4, + "x": 4, + "y": 8 + }, + "id": 3, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "(sum(metadata_coverage_success_total{provider=\"codex\", field=\"logo\"}) / sum(metadata_coverage_checks_total{provider=\"codex\", field=\"logo\"})) * 100", + "instant": true, + "legendFormat": "Logo", + "refId": "A" + } + ], + "title": "Codex - Logo %", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "yellow", + "value": 30 + }, + { + "color": "green", + "value": 70 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 4, + "x": 8, + "y": 8 + }, + "id": 11, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "(sum(metadata_coverage_success_total{provider=\"jupiter\", field=\"logo\"}) / sum(metadata_coverage_checks_total{provider=\"jupiter\", field=\"logo\"})) * 100", + "instant": true, + "legendFormat": "Logo", + "refId": "A" + } + ], + "title": "Jupiter - Logo % (Solana)", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "yellow", + "value": 30 + }, + { + "color": "green", + "value": 70 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 4, + "x": 12, + "y": 8 + }, + "id": 4, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "(sum(metadata_coverage_success_total{provider=\"mobula\", field=\"description\"}) / sum(metadata_coverage_checks_total{provider=\"mobula\", field=\"description\"})) * 100", + "instant": true, + "legendFormat": "Desc", + "refId": "A" + } + ], + "title": "Mobula - Description %", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "yellow", + "value": 30 + }, + { + "color": "green", + "value": 70 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 4, + "x": 16, + "y": 8 + }, + "id": 5, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "(sum(metadata_coverage_success_total{provider=\"codex\", field=\"description\"}) / sum(metadata_coverage_checks_total{provider=\"codex\", field=\"description\"})) * 100", + "instant": true, + "legendFormat": "Desc", + "refId": "A" + } + ], + "title": "Codex - Description %", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "percent" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#1F78B4", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*codex.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#F4D03F", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*jupiter.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#27AE60", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 13 + }, + "id": 6, + "options": { + "legend": { + "calcs": [ + "mean", + "lastNotNull" + ], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "(sum(metadata_coverage_success_total{field=\"logo\"}) by (provider) / sum(metadata_coverage_checks_total{field=\"logo\"}) by (provider)) * 100", + "legendFormat": "{{provider}}", + "range": true, + "refId": "A" + } + ], + "title": "Logo Coverage Over Time", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "percent" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#1F78B4", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*codex.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#F4D03F", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*jupiter.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#27AE60", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 13 + }, + "id": 7, + "options": { + "legend": { + "calcs": [ + "mean", + "lastNotNull" + ], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "(sum(metadata_coverage_success_total{field=\"description\"}) by (provider) / sum(metadata_coverage_checks_total{field=\"description\"}) by (provider)) * 100", + "legendFormat": "{{provider}}", + "range": true, + "refId": "A" + } + ], + "title": "Description Coverage Over Time", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "ms" + }, + "overrides": [ + { + "matcher": { + "id": "byRegexp", + "options": ".*mobula.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#1F78B4", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*codex.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#F4D03F", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byRegexp", + "options": ".*jupiter.*" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#27AE60", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 21 + }, + "id": 8, + "options": { + "legend": { + "calcs": [ + "mean", + "lastNotNull", + "max" + ], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.50, sum(rate(metadata_api_latency_milliseconds_bucket[5m])) by (le, provider))", + "legendFormat": "{{provider}} (P50)", + "range": true, + "refId": "A" + } + ], + "title": "Metadata API Latency (P50)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 8, + "x": 0, + "y": 29 + }, + "id": 9, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(metadata_coverage_checks_total{field=\"logo\"}) by (provider)", + "instant": true, + "legendFormat": "{{provider}}", + "refId": "A" + } + ], + "title": "Total Tokens Checked by Provider", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 16, + "x": 8, + "y": 29 + }, + "id": 10, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "sum(metadata_coverage_checks_total{field=\"logo\"}) by (chain)", + "instant": true, + "legendFormat": "{{chain}}", + "refId": "A" + } + ], + "title": "Tokens Checked by Chain", + "type": "stat" + } + ], + "refresh": "30s", + "schemaVersion": 39, + "tags": [ + "metadata", + "coverage", + "logo", + "benchmark", + "mobula", + "codex", + "jupiter" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "Metadata Coverage Benchmark", + "uid": "metadata_coverage_benchmark", + "version": 0, + "weekStart": "" +} \ No newline at end of file diff --git a/infrastructure/monitoring/grafana/grafana-entrypoint.sh b/infrastructure/monitoring/grafana/grafana-entrypoint.sh new file mode 100755 index 00000000..ccf63ca7 --- /dev/null +++ b/infrastructure/monitoring/grafana/grafana-entrypoint.sh @@ -0,0 +1,29 @@ +#!/bin/sh + +echo "=== GRAFANA ENTRYPOINT SCRIPT STARTING ===" +echo "Working directory: $(pwd)" +echo "User: $(whoami)" + +# Create dashboards directory +mkdir -p /var/lib/grafana/dashboards + +# Debug: print environment variables +echo "Checking environment..." +env | grep -E '(RAILWAY|HIDE_QUOTE)' || echo "No RAILWAY/HIDE_QUOTE variables found" + +# Copy dashboards from source +if [ "$HIDE_QUOTE_DASHBOARD" = "true" ]; then + echo "HIDE_QUOTE_DASHBOARD=true - hiding Quote API Latency Benchmark dashboard" + cp /dashboards-source/head_lag.json /var/lib/grafana/dashboards/ +else + echo "Copying all dashboards" + cp /dashboards-source/*.json /var/lib/grafana/dashboards/ +fi + +echo "Dashboards copied:" +ls -la /var/lib/grafana/dashboards/ + +echo "=== GRAFANA ENTRYPOINT SCRIPT COMPLETE ===" + +# Start Grafana with default entrypoint +exec /run.sh diff --git a/infrastructure/monitoring/grafana/provisioning/dashboards/dashboard.yml b/infrastructure/monitoring/grafana/provisioning/dashboards/dashboard.yml new file mode 100644 index 00000000..332c8f34 --- /dev/null +++ b/infrastructure/monitoring/grafana/provisioning/dashboards/dashboard.yml @@ -0,0 +1,13 @@ +apiVersion: 1 + +providers: + - name: 'Aggregator Latency Dashboards' + orgId: 1 + folder: '' + type: file + disableDeletion: false + updateIntervalSeconds: 10 + allowUiUpdates: false + options: + path: /var/lib/grafana/dashboards + foldersFromFilesStructure: true diff --git a/infrastructure/monitoring/grafana/provisioning/datasources/prometheus.yml b/infrastructure/monitoring/grafana/provisioning/datasources/prometheus.yml new file mode 100644 index 00000000..30d5cc21 --- /dev/null +++ b/infrastructure/monitoring/grafana/provisioning/datasources/prometheus.yml @@ -0,0 +1,10 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: ${PROMETHEUS_URL} + uid: prometheus + isDefault: true + editable: false diff --git a/infrastructure/monitoring/prom-gateway/Caddyfile b/infrastructure/monitoring/prom-gateway/Caddyfile new file mode 100644 index 00000000..950ca185 --- /dev/null +++ b/infrastructure/monitoring/prom-gateway/Caddyfile @@ -0,0 +1,51 @@ +{ + # Caddy runs as a public-facing reverse proxy in front of Prometheus. + # Admin paths require X-Admin-Token; read paths are open. + auto_https off + admin off + log { + output stdout + format json + level INFO + } +} + +:{$PORT:8080} { + # ---- Response compression ---- + # Explicit Caddy-side compression so /api/v1/query_range JSON gets zstd + # (~15% smaller than gzip) regardless of Railway edge behaviour. Browsers + # negotiate the best supported encoding; clients with no Accept-Encoding + # still get an uncompressed response. + encode zstd gzip + + # ---- Local health probe (Railway healthcheck hits this) ---- + handle /healthz { + respond "ok" 200 + } + + # ---- Gated admin surface ---- + # Anything that can mutate TSDB or kill the process must carry the token. + @admin { + path /api/v1/admin/* /-/reload /-/quit + } + handle @admin { + @authed header X-Admin-Token {env.PROM_ADMIN_TOKEN} + handle @authed { + reverse_proxy {env.PROM_UPSTREAM} { + header_up Host {upstream_hostport} + } + } + respond "forbidden" 403 { + close + } + } + + # ---- Public read surface ---- + # /api/v1/query, /api/v1/query_range, /api/v1/series, /api/v1/label, + # /api/v1/labels, /api/v1/targets, /metrics, /graph, etc. + handle { + reverse_proxy {env.PROM_UPSTREAM} { + header_up Host {upstream_hostport} + } + } +} diff --git a/infrastructure/monitoring/prom-gateway/Dockerfile b/infrastructure/monitoring/prom-gateway/Dockerfile new file mode 100644 index 00000000..8db5357b --- /dev/null +++ b/infrastructure/monitoring/prom-gateway/Dockerfile @@ -0,0 +1,12 @@ +FROM caddy:2-alpine + +COPY Caddyfile /etc/caddy/Caddyfile + +# Default to localhost so the validate step works without any env vars. +# Railway overrides this to the Prom service's internal DNS, e.g. +# prometheus.railway.internal:9090 +ENV PROM_UPSTREAM=127.0.0.1:9090 + +EXPOSE 8080 + +CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"] diff --git a/infrastructure/monitoring/prom-gateway/README.md b/infrastructure/monitoring/prom-gateway/README.md new file mode 100644 index 00000000..8a04fbf5 --- /dev/null +++ b/infrastructure/monitoring/prom-gateway/README.md @@ -0,0 +1,53 @@ +# prom-gateway + +Caddy reverse proxy that sits in front of the OpenChainBench Prometheus on +Railway. The Prom service stays internal-only; this gateway is the single +public entrypoint. + +## What it does + +- Public read API (`/api/v1/query*`, `/api/v1/series`, `/api/v1/label*`, + `/metrics`, `/graph`, etc.) passes through unauthenticated. Vercel's bench + pages keep working with no change. +- Admin API (`/api/v1/admin/*`) plus the lifecycle endpoints (`/-/reload`, + `/-/quit`) require header `X-Admin-Token: $PROM_ADMIN_TOKEN`. Missing or + wrong token returns `403 forbidden`. +- `GET /healthz` returns `200 ok` for the Railway healthcheck. +- Access logs go to stdout in JSON. + +## How the token works + +Set a long random secret in Railway as the `PROM_ADMIN_TOKEN` env var of the +`prom-gateway` service. The Caddyfile reads it at startup via +`{env.PROM_ADMIN_TOKEN}`. Rotate by changing the env var and redeploying; +Prom itself never sees the token. + +## Why two Railway services (not a sidecar in the Prom container) + +The Prom upstream Dockerfile (`prom/prometheus:v2.49.1`) is kept clean. Caddy +ships as its own service so the two restart, scale and roll independently. +Token rotation = redeploy of ~10 MB of Caddy, the Prom scrape state stays in +RAM. Prom binds to Railway's internal DNS only, so the only way in is through +Caddy. + +## Client usage (future delete-ui Next.js app) + +```bash +# Read (no auth): +curl -s "https://prom-gateway-production.up.railway.app/api/v1/query?query=up" + +# Admin (token required): +curl -X POST -H "X-Admin-Token: $TOKEN" \ + "https://prom-gateway-production.up.railway.app/api/v1/admin/tsdb/delete_series?match[]={benchmark=\"l1-finality\"}" +``` + +## Railway deploy (step-by-step) + +1. In the Railway project that hosts Prometheus, click **+ New** → **Empty Service**. Name it `prom-gateway`. +2. **Settings → Source**: connect this repo, set **Root Directory** to `infrastructure/monitoring/prom-gateway`. Railway picks up the `Dockerfile` and `railway.toml` automatically. +3. **Variables**: add + - `PROM_ADMIN_TOKEN` = output of `openssl rand -hex 32` (save it in 1Password before pasting). + - `PROM_UPSTREAM` = `prometheus.railway.internal:9090` (use the actual internal DNS name of your Prom service; check the Prom service's **Settings → Networking → Private Networking**). +4. **Settings → Networking**: click **Generate Domain**. Railway prints a public URL like `prom-gateway-production-xxxx.up.railway.app`. +5. On the existing `prometheus` service: remove its public domain (**Settings → Networking → remove public domain**) so only the gateway is reachable. Prom stays available at `prometheus.railway.internal:9090` for the gateway. +6. Update Vercel env `NEXT_PUBLIC_PROMETHEUS_URL` to the new gateway domain. diff --git a/infrastructure/monitoring/prom-gateway/railway.toml b/infrastructure/monitoring/prom-gateway/railway.toml new file mode 100644 index 00000000..0e3ba482 --- /dev/null +++ b/infrastructure/monitoring/prom-gateway/railway.toml @@ -0,0 +1,10 @@ +[build] +builder = "DOCKERFILE" +dockerfilePath = "Dockerfile" + +[deploy] +startCommand = "caddy run --config /etc/caddy/Caddyfile --adapter caddyfile" +healthcheckPath = "/healthz" +healthcheckTimeout = 30 +restartPolicyType = "ON_FAILURE" +restartPolicyMaxRetries = 10 diff --git a/infrastructure/monitoring/prometheus/Dockerfile b/infrastructure/monitoring/prometheus/Dockerfile new file mode 100644 index 00000000..e55ac0c8 --- /dev/null +++ b/infrastructure/monitoring/prometheus/Dockerfile @@ -0,0 +1,60 @@ +FROM prom/prometheus:v2.49.1 + +USER root + +# Pick which prometheus.yml to bake in (prometheus.yml for prod, prometheus.staging.yml for staging). +ARG PROMETHEUS_CONFIG=prometheus.yml + +COPY ${PROMETHEUS_CONFIG} /etc/prometheus/prometheus.yml +COPY alert_rules.yml /etc/prometheus/alert_rules.yml +COPY recording_rules/ /etc/prometheus/recording_rules/ +COPY entrypoint.sh /entrypoint.sh +RUN chmod 755 /entrypoint.sh + +EXPOSE 9090 + +# Wrap the prom binary so HL_BENCH_LOCAL_AUTH (and future runtime secrets) +# get written to /etc/prometheus/secrets/* files that prometheus.yml +# references via *_file directives — Prom 2.x doesn't expand ${ENV} in +# arbitrary config fields, so we materialise secrets to disk at start. +ENTRYPOINT ["/entrypoint.sh"] + +# --enable-feature=expand-external-labels: lets global.external_labels read ${ENVIRONMENT} from env +# --storage.tsdb.retention.time=180d: keep historical samples 6 months so +# sponsor pitches + bench pages can show multi-month trends. Default is +# 15d which is too short for our use case. +# --storage.tsdb.retention.size=10GB: hard ceiling on disk to prevent the +# Railway volume filling up if cardinality spikes. With ~500 active +# series at 10s scrape the projected footprint is ~2-3 GB over 180d, so +# 10 GB is a comfortable safety margin. Prom evicts oldest data on +# whichever cap (time or size) trips first. +# --query.lookback-delta=1h: extends Prom's stale window from the 5min +# default to 1h. Instant queries against gauges that haven't been +# re-scraped in <1h still return the last value instead of empty. +# Critical for slow-cadence harnesses (validator-yield, buyback-audit, +# network-coverage) where bare-metric queries on the OCB site were +# transiently flipping to "AWAITING" between scrapes. +# +# NOTE: --web.enable-lifecycle was previously disabled because the Railway +# URL was public with no auth, so POST /-/quit would let anyone kill the +# container. We now front Prom with the prom-gateway Caddy service +# (../prom-gateway/), which terminates all public traffic, gates the admin +# + lifecycle paths behind X-Admin-Token, and proxies the read API openly. +# Prom itself binds on 0.0.0.0:9090 so the gateway can reach it over +# Railway internal DNS (prometheus.railway.internal:9090). The public +# domain MUST be removed from this Prom service in Railway settings — +# only the prom-gateway service should have a generated domain. +# +# --web.enable-admin-api: required for POST /api/v1/admin/tsdb/delete_series +# so the delete-ui can wipe per-bench data. Gated upstream by Caddy. +# --web.enable-lifecycle: required for POST /-/reload and /-/quit. Gated +# upstream by Caddy. +CMD ["--config.file=/etc/prometheus/prometheus.yml", \ + "--storage.tsdb.path=/prometheus", \ + "--storage.tsdb.retention.time=180d", \ + "--storage.tsdb.retention.size=10GB", \ + "--enable-feature=expand-external-labels", \ + "--query.lookback-delta=1h", \ + "--web.listen-address=0.0.0.0:9090", \ + "--web.enable-admin-api", \ + "--web.enable-lifecycle"] diff --git a/infrastructure/monitoring/prometheus/alert_rules.yml b/infrastructure/monitoring/prometheus/alert_rules.yml new file mode 100644 index 00000000..b4501d99 --- /dev/null +++ b/infrastructure/monitoring/prometheus/alert_rules.yml @@ -0,0 +1,120 @@ +groups: + - name: aggregator_latency_alerts + interval: 30s + rules: + # NOTE: rules depending on rest_api_latency_* / rest_api_errors_total were + # removed when the binary was split — those metrics belong to a future + # bench (REST API latency) that doesn't exist yet. Reintroduce them when + # that harness ships. + + # Head lag (WebSocket latency) alerts - per-chain thresholds + # NOTE: alerts use fixed-cardinality gauges now (no tx_hash label) to avoid stale-series spam. + # Sustained 30s window avoids single-spike firings (reconnect bursts, transient network jitter). + - alert: MobulaHeadLagSpikeBase + expr: mobula_head_lag_detailed_seconds{chain="base"} > 2.5 + for: 30s + labels: + severity: warning + aggregator: mobula + alert_type: head_lag_spike + app: aggregator_latency_monitor + chain: base + annotations: + summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}Mobula head lag spike on Base' + description: | + **Mobula WebSocket Latency Spike - Base** + + **Latency:** {{ $value }}s (threshold: 2.5s, sustained ≥30s) + • Mobula Processing: {{ with query (printf "mobula_processing_lag_seconds{chain=\"base\",region=\"%s\",pool_address=\"%s\"}" $labels.region $labels.pool_address) }}{{ . | first | value }}s{{ end }} + • Network: {{ with query (printf "mobula_network_lag_seconds{chain=\"base\",region=\"%s\",pool_address=\"%s\"}" $labels.region $labels.pool_address) }}{{ . | first | value }}s{{ end }} + + • Pool: {{ $labels.pool_address }} + • Region: {{ $labels.region }} + • Latest tx: {{ with query (printf "mobula_last_tx_hash{chain=\"base\",region=\"%s\",pool_address=\"%s\"}" $labels.region $labels.pool_address) }}https://basescan.org/tx/{{ . | first | label "tx_hash" }}{{ end }} + + - alert: MobulaHeadLagSpikeSolana + expr: mobula_head_lag_detailed_seconds{chain="solana"} > 2 + for: 30s + labels: + severity: warning + aggregator: mobula + alert_type: head_lag_spike + app: aggregator_latency_monitor + chain: solana + annotations: + summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}Mobula head lag spike on Solana' + description: | + **Mobula WebSocket Latency Spike - Solana** + + **Latency:** {{ $value }}s (threshold: 2s, sustained ≥30s) + • Mobula Processing: {{ with query (printf "mobula_processing_lag_seconds{chain=\"solana\",region=\"%s\",pool_address=\"%s\"}" $labels.region $labels.pool_address) }}{{ . | first | value }}s{{ end }} + • Network: {{ with query (printf "mobula_network_lag_seconds{chain=\"solana\",region=\"%s\",pool_address=\"%s\"}" $labels.region $labels.pool_address) }}{{ . | first | value }}s{{ end }} + + • Pool: {{ $labels.pool_address }} + • Region: {{ $labels.region }} + • Latest tx: {{ with query (printf "mobula_last_tx_hash{chain=\"solana\",region=\"%s\",pool_address=\"%s\"}" $labels.region $labels.pool_address) }}https://solscan.io/tx/{{ . | first | label "tx_hash" }}{{ end }} + + - alert: MobulaHeadLagSpikeBNB + expr: mobula_head_lag_detailed_seconds{chain="bnb"} > 2.5 + for: 30s + labels: + severity: warning + aggregator: mobula + alert_type: head_lag_spike + app: aggregator_latency_monitor + chain: bnb + annotations: + summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}Mobula head lag spike on BNB Chain' + description: | + **Mobula WebSocket Latency Spike - BNB Chain** + + **Latency:** {{ $value }}s (threshold: 2.5s, sustained ≥30s) + • Mobula Processing: {{ with query (printf "mobula_processing_lag_seconds{chain=\"bnb\",region=\"%s\",pool_address=\"%s\"}" $labels.region $labels.pool_address) }}{{ . | first | value }}s{{ end }} + • Network: {{ with query (printf "mobula_network_lag_seconds{chain=\"bnb\",region=\"%s\",pool_address=\"%s\"}" $labels.region $labels.pool_address) }}{{ . | first | value }}s{{ end }} + + • Pool: {{ $labels.pool_address }} + • Region: {{ $labels.region }} + • Latest tx: {{ with query (printf "mobula_last_tx_hash{chain=\"bnb\",region=\"%s\",pool_address=\"%s\"}" $labels.region $labels.pool_address) }}https://bscscan.com/tx/{{ . | first | label "tx_hash" }}{{ end }} + + # Missing head lag metrics (no data for 5 minutes = monitor down) + - alert: MissingHeadLagMetrics + expr: absent(head_lag_seconds) + for: 5m + labels: + severity: critical + alert_type: missing_head_lag + app: aggregator_latency_monitor + annotations: + summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}Head lag metrics missing' + description: "No head_lag_seconds metrics received for 5 minutes. Check if monitors are running." + + # Per-aggregator staleness: fires when one provider stops pushing data + # (e.g. Codex WS disconnect) while others keep running — global absent() + # would NOT catch this. + - alert: AggregatorHeadLagStale + expr: (time() - timestamp(head_lag_seconds)) > 300 + for: 1m + labels: + severity: warning + alert_type: head_lag_stale + app: aggregator_latency_monitor + annotations: + summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}{{ $labels.aggregator }} head_lag stale on {{ $labels.chain }} ({{ $labels.region }})' + description: | + **{{ $labels.aggregator }}** hasn't pushed a head_lag sample for **{{ $labels.chain }} / {{ $labels.region }}** in over 5 minutes. + + Likely cause: WebSocket disconnected, JWT expired, proxy IP banned, or auth cookie rotated. + + Check the monitor logs for `[HEAD-LAG][{{ $labels.aggregator | toUpper }}]` errors. + + # Service availability + - alert: CodexServiceDown + expr: up{job="latency_monitor"} == 0 + for: 2m + labels: + severity: critical + alert_type: service_down + app: aggregator_latency_monitor + annotations: + summary: '{{ if eq $externalLabels.environment "staging" }}[STAGING] {{ end }}Latency monitor service is down' + description: "The aggregator latency monitor has been down for 2 minutes. No metrics are being collected." diff --git a/infrastructure/monitoring/prometheus/entrypoint.sh b/infrastructure/monitoring/prometheus/entrypoint.sh new file mode 100755 index 00000000..bc118749 --- /dev/null +++ b/infrastructure/monitoring/prometheus/entrypoint.sh @@ -0,0 +1,38 @@ +#!/bin/sh +# entrypoint.sh — write runtime secrets to files, then exec prometheus. +# +# Prometheus 2.x does not expand ${ENV_VAR} inside arbitrary config fields +# (only inside global.external_labels under --enable-feature= +# expand-external-labels). Configs that need a runtime secret must use a +# *_file directive pointing at a file whose contents are the secret. +# +# This script materialises each known runtime secret into a file under +# /etc/prometheus/secrets/ before exec'ing the prometheus binary. Files are +# 0400 root-owned so only the prometheus process (running as root by virtue +# of FROM prom/prometheus's USER directive) can read them. + +set -eu + +SECRETS_DIR=/etc/prometheus/secrets +mkdir -p "$SECRETS_DIR" +chmod 700 "$SECRETS_DIR" + +write_secret() { + name="$1" + value="$2" + if [ -n "$value" ]; then + printf '%s' "$value" > "$SECRETS_DIR/$name" + chmod 400 "$SECRETS_DIR/$name" + else + # Write empty file so password_file resolves; Prom will still send + # an empty password rather than failing config parse. + : > "$SECRETS_DIR/$name" + chmod 400 "$SECRETS_DIR/$name" + fi +} + +# Secret for the hl-frontends-local-v2 scrape (Caddy basic_auth on OVH). +write_secret hl_bench_local_auth "${HL_BENCH_LOCAL_AUTH:-}" + +# Hand off to prometheus with all CMD args preserved. +exec /bin/prometheus "$@" diff --git a/infrastructure/monitoring/prometheus/prometheus.staging.yml b/infrastructure/monitoring/prometheus/prometheus.staging.yml new file mode 100644 index 00000000..8660f988 --- /dev/null +++ b/infrastructure/monitoring/prometheus/prometheus.staging.yml @@ -0,0 +1,29 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + # Staging external label triggers [STAGING] prefix in alert summaries via templating. + external_labels: + environment: 'staging' + +# Load alert rules (shared with production; [STAGING] prefix is conditional via $externalLabels) +rule_files: + - '/etc/prometheus/alert_rules.yml' + # OCB recording rules: precomputed per-cell aggregates the bench site + # reads as cheap ocb:* selectors instead of 24h-window quantiles. + - '/etc/prometheus/recording_rules/*.yml' + +# Staging Alertmanager (separate from production) +alerting: + alertmanagers: + - static_configs: + - targets: ['alertmanager-staging.railway.internal:9093'] + +# Staging scrape targets — 3 regions, same pattern as production +scrape_configs: + - job_name: 'monitor-staging' + static_configs: + - targets: + - 'agg-staging-eu.railway.internal:2112' + - 'agg-staging-us.railway.internal:2112' + - 'agg-staging-sgp.railway.internal:2112' + metrics_path: /metrics diff --git a/infrastructure/monitoring/prometheus/prometheus.yml b/infrastructure/monitoring/prometheus/prometheus.yml new file mode 100644 index 00000000..2c4d3e94 --- /dev/null +++ b/infrastructure/monitoring/prometheus/prometheus.yml @@ -0,0 +1,452 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + # Expanded at startup via --enable-feature=expand-external-labels. + # Unset => empty value => alert rule templates treat it as "not staging" and don't prefix. + external_labels: + environment: '${ENVIRONMENT}' + +# Load alert rules +rule_files: + - '/etc/prometheus/alert_rules.yml' + # OCB recording rules: precomputed per-cell aggregates the bench site + # reads as cheap ocb:* selectors instead of 24h-window quantiles. + - '/etc/prometheus/recording_rules/*.yml' + +# Alertmanager configuration +alerting: + alertmanagers: + - static_configs: + - targets: ['alertmanager.railway.internal:9093'] + +scrape_configs: + - job_name: 'monitor' + static_configs: + - targets: + - 'agg-eu-west.railway.internal:2112' + - 'alert-reflection.railway.internal:2112' + - 'aggregator-latency-benchmark.railway.internal:2112' + metrics_path: /metrics + + # OpenChainBench bench №004 — runs the Pulse V2 feeder + metadata + # coverage worker. Internal-only Railway service; no public URL needed. + - job_name: 'metadata-coverage' + static_configs: + - targets: + - 'metadata-coverage.railway.internal:2112' + labels: + benchmark: metadata-coverage + metrics_path: /metrics + + # OpenChainBench bench №040 — polls 4 NFT-metadata APIs (Moralis, + # Alchemy, OpenSea, Rarible) every 6h on a fixed list of 50 Ethereum + # blue-chip collections. 5 fields scored (name/image/description/ + # floor_eth/external_url); Rarible's floor_eth is intentionally not + # recorded (BID vs ASK asymmetry, see bench spec). Internal-only. + - job_name: 'nft-metadata-coverage' + static_configs: + - targets: + - 'nft-metadata-coverage.railway.internal:2112' + labels: + benchmark: nft-collection-metadata + metrics_path: /metrics + + # OpenChainBench bench №005 — counts the chains each onchain data + # provider officially supports (GeckoTerminal, Codex, Mobula). + - job_name: 'network-coverage' + static_configs: + - targets: + - 'network-coverage.railway.internal:2112' + labels: + benchmark: network-coverage + metrics_path: /metrics + + # Per-chain KPI exporter — TVL/DEX/stables from DefiLlama, native + # price+mcap and Mobula-tokens-indexed from Mobula. Powers the KPI + # strip on /chains/. Not a bench, but exposes gauges read by + # the site SSR pass. + - job_name: 'chain-kpis' + static_configs: + - targets: + - 'chain-kpis.railway.internal:2112' + labels: + benchmark: chain-kpis + metrics_path: /metrics + + # Per-venue prediction-market KPI exporter. Polls Polymarket gamma, + # Kalshi REST, DefiLlama for the /prediction-markets hub. Powers the + # cohort leaderboard + per-venue anchored sections. Not a bench, but + # exposes gauges read by the site SSR pass. + - job_name: 'pm-cohort-stats' + static_configs: + - targets: + - 'pm-cohort-stats.railway.internal:2112' + labels: + benchmark: pm-cohort-stats + metrics_path: /metrics + + # Per-venue perpetual DEX cohort exporter. Multi-source adapter (HL + # native, Lighter native, DefiLlama HTML, Mobula funding + pairs) + # publishes perp_venue_* gauges read by the /perps hub leaderboard, + # PerpVenueKpiStrip and per-product PerpVenueSection. Tier 1 cohort: + # hyperliquid, lighter, gmx-v2, gains. Internal-only Railway service. + - job_name: 'perp-cohort-stats' + static_configs: + - targets: + - 'perp-cohort-stats.railway.internal:2112' + labels: + benchmark: perp-cohort-stats + metrics_path: /metrics + + # OpenChainBench bench №006 — measures block-level finality lag across + # 13 L1 chains by reading latest vs finalized block every 10 s. + - job_name: 'l1-finality' + static_configs: + - targets: + - 'l1-finality.railway.internal:2112' + labels: + benchmark: l1-finality + metrics_path: /metrics + + # OpenChainBench bench №007 — measures all-in opening cost across perp + # venues (Hyperliquid, dYdX, GMX, Lighter, Gains via Mobula). 5 min cadence. + - job_name: 'perp-fees' + static_configs: + - targets: + - 'perp-fee.railway.internal:2112' + labels: + benchmark: perp-fees + metrics_path: /metrics + + # OpenChainBench bench №008 — wallet label coverage across 9 providers + # (Mobula, Moralis, Helius, Blockscout, OLI, TonAPI, StellarExpert, XRPScan, + # WalletExplorer) on 11 chains. Anchor sample of ~60 well-known addresses + # rotated every 30 min. + # Service is currently named `mobula-monorepo` on Railway (default repo + # name); when it gets renamed to `wallet-labels`, update this target. + - job_name: 'wallet-labels' + static_configs: + - targets: + - 'mobula-monorepo.railway.internal:2112' + labels: + benchmark: wallet-labels + metrics_path: /metrics + + # OpenChainBench bench №009 — live sequencer block-time across 9 EVM L2s + # (Arbitrum, Optimism, Base, zkSync Era, Linea, Scroll, Blast, Mantle, + # Taiko). One persistent eth_subscribe(newHeads) WS per chain; metric + # is the wall-clock interval between two consecutive head events. + - job_name: 'l2-block-time' + static_configs: + - targets: + - 'l2-block-time.railway.internal:2112' + labels: + benchmark: l2-block-time + metrics_path: /metrics + + # OpenChainBench bench №010 / 011 / 012 - public RPC capabilities. + # One Go binary probes (provider x chain) for: (a) eth_blockNumber + # latency p50/p90/p99, (b) error-rate broken into http/jsonrpc/stale/ + # timeout buckets, (c) archive depth via eth_getBalance at multiple + # historical heights. Covers Ethereum, Base, BNB across publicnode, + # drpc, 1rpc, meowrpc, flashbots, cloudflare-eth + chain-official RPCs. + # + # Multi-region (since 2026-05-29): runs as 3 separate Railway services, + # one per region (us-east, eu-west, sgp). Each has its own internal + # DNS so Prom can scrape all three explicitly — the single-service + + # replicas approach didn't work because Railway's internal DNS is + # geo-sticky and always routed Prom to the closest replica. + # + # honor_labels: the harness self-attributes region/provider/chain + # via labels in its /metrics output (REGION env var on each service). + # We keep those as-is and only add the bench grouping label below. + - job_name: 'rpc-capabilities' + honor_labels: true + static_configs: + - targets: + - 'rpc-capabilities-bench.railway.internal:2112' # sgp + - 'rpc-capabilities-eu.railway.internal:2112' # eu-west + - 'rpc-capabilities-us.railway.internal:2112' # us-east + labels: + benchmark: rpc-capabilities + metrics_path: /metrics + + # OpenChainBench bench №013 - Ethereum gas oracle prediction accuracy. + # Polls Blocknative + PublicNode feeHistory + Owlracle + Etherscan v2, + # waits 2 blocks, computes realized p25/p50/p90 priority fee from the + # mined block's txs, emits |predicted - realized| per oracle per tier. + - job_name: 'gas-estimation' + static_configs: + - targets: + - 'gas-fee-estimation.railway.internal:2112' + labels: + benchmark: gas-estimation + metrics_path: /metrics + + # OpenChainBench bench №014 - stablecoin peg deviation vs $1.00. + # Polls Binance, Kraken, Bitstamp REST + Curve 3pool eth_call, + # aggregates per-minute liquidity-weighted median across venues, + # emits absolute deviation in bps + the cross-venue gap (max-min) + # that nobody else publishes. + - job_name: 'stablecoin-peg' + static_configs: + - targets: + - 'stablecoin-peg-bench.railway.internal:2112' + labels: + benchmark: stablecoin-peg + metrics_path: /metrics + + # OpenChainBench bench №016 - Solana tx landing services market share. + # Observational. Subscribes to mainnet-beta WS via logsSubscribe on + # ~72 known tip wallets and attributes each landed tx to its service + # (Jito, Helius Sender, Nozomi, bloXroute, 0slot, NextBlock, + # Astralane, SolanaVibeStation). Zero on-chain footprint. + - job_name: 'solana-tx-landing' + static_configs: + - targets: + - 'solana-tx-landing.railway.internal:2112' + labels: + benchmark: solana-tx-landing + metrics_path: /metrics + + # OpenChainBench bench №018 - Buyback Execution Audit. + # Measures executed_USD / promised_USD on-chain per protocol over + # 7d & 30d windows. Hyperliquid Assistance Fund via HL info API, + # Sky SBE via Etherscan v2 tokentx (SKY inflows). Requires + # ETHERSCAN_API_KEY env var for Sky data. + - job_name: 'buyback-audit' + static_configs: + - targets: + - 'buyback-audit.railway.internal:2112' + labels: + benchmark: buyback-audit + metrics_path: /metrics + + # OpenChainBench bench №025 - Oracle Deviation. + # 4 oracles × 10 USD pairs: Chainlink AggregatorV3 (eth_call), + # Pyth Hermes batch, Binance ticker, Coinbase ticker. 30s polling. + # Exposes per-source price, pairwise deviation, max deviation, latency. + - job_name: 'oracle-deviation' + static_configs: + - targets: + - 'oracle-deviation.railway.internal:2112' + labels: + benchmark: oracle-deviation + metrics_path: /metrics + + # OpenChainBench bench №026 - Validator Net Yield Comparison. + # Solana via Stakewiz (total_apy, MEV-included) + Jito Kobe enrichment. + # Hyperliquid via /info validatorSummaries. Top-200 Solana by stake + + # all HL validators. MEV exposed for transparency but not subtracted + # (gross APR includes Jito by upstream design). + - job_name: 'validator-yield' + static_configs: + - targets: + - 'validator-yield.railway.internal:2112' + labels: + benchmark: validator-yield + metrics_path: /metrics + + # OpenChainBench bench №028 - Relay.link Revenue (implied margin). + # Polls https://api.relay.link/requests every 60s, dedups by id, + # computes implied_margin_usd = usd_in - usd_out - sum(gas) - sum(appFees) + # per success swap, stores in SQLite, exposes rolling 24h/7d/30d + # revenue/volume/count + take_rate_bps gauges. This is the CEILING. + - job_name: 'relay-revenue' + static_configs: + - targets: + - 'relay-revenue.railway.internal:2112' + labels: + benchmark: relay-revenue + metrics_path: /metrics + + # OpenChainBench bench №028 - Relay.link Revenue (FLOOR via solver wallet). + # Polls Mobula /wallet/portfolio every 5 min for the Relay solver EOA + # 0xf70da97812CB96acDF810712Aa562db8dfA3dbEF and tracks rolling balance + # delta over 24h/7d/30d as a ground-truth floor on captured margin. + # Stable-only delta strips ETH price-volatility noise. + - job_name: 'relay-revenue-floor' + static_configs: + - targets: + - 'relay-revenue-bn4b.railway.internal:2112' + labels: + benchmark: relay-revenue + metrics_path: /metrics + + # OpenChainBench bench №029 - Solana DEX quote latency. + # Probes Jupiter / Mobula / OpenOcean / Raydium quote APIs every 60s + # for the canonical 1 SOL → USDC 50 bps quote from 3 regions, emits + # histogram solana_quote_latency_ms + success/throttle/auth counters. + # Each service self-labels via MONITOR_REGION env. + # + # Internal DNS suffixes (-jd9g-cwjh / -h888-za1j / -i105-ytds) were + # auto-assigned by Railway when the 3 services were provisioned. + - job_name: 'solana-quote-latency' + scrape_interval: 30s + scrape_timeout: 15s + static_configs: + - targets: + - 'solana-quote-us-east-jd9g-cwjh.railway.internal:2112' + - 'solana-quote-eu-west-h888-za1j.railway.internal:2112' + - 'solana-quote-sgp-i105-ytds.railway.internal:2112' + labels: + benchmark: solana-quote-latency + metrics_path: /metrics + + # OpenChainBench bench №030 - Hyperliquid frontends user-cost benchmark. + # Pulls Hyperliquid's per-builder, per-day fills CSV dumps from + # stats-data.hyperliquid.xyz, aggregates 24h notional + builder_fee + + # unique users per builder address, exposes effective fee bps, $/user, + # volume share. The differentiator vs every other HL dashboard is + # D7/D30 cohort retention computed from a local SQLite state — the + # service runs with a persistent volume at /data so SQLite survives + # restarts. Single-region single-instance (no geo split needed for a + # CSV-poll bench), 1h cycle cadence (matches the upstream daily CSV + # rollover), allow a generous scrape_timeout in case the LZ4 decode + # of a busy builder's CSV takes longer than the default. + - job_name: 'hyperliquid-frontends' + scrape_interval: 30s + scrape_timeout: 15s + static_configs: + - targets: + - 'hyperliquid-frontends.railway.internal:2112' + labels: + benchmark: hyperliquid-frontends + metrics_path: /metrics + + # OpenChainBench bench №030 v2 — same bench, different data source. Reads + # the local hl-node output on the OVH SGP server (15.235.224.14) at + # sub-minute freshness instead of the daily CSV bucket (24-48h lag). The + # harness binds 127.0.0.1:2113 on that host; Caddy fronts :8088 with + # basic_auth so only Prom with the credential can scrape. Metrics use a + # `_v2` suffix to coexist with v1 during A/B validation; after ~1 week of + # parity we'll deprecate v1 and drop the suffix. + - job_name: 'hyperliquid-frontends-local-v2' + scrape_interval: 30s + scrape_timeout: 10s + static_configs: + - targets: + - '15.235.224.14:8088' + labels: + benchmark: hyperliquid-frontends + source: local-node + metrics_path: /metrics + basic_auth: + username: ocb_scraper + # Prometheus 2.x doesn't expand ${ENV} in arbitrary fields (only in + # external_labels). We use password_file pointing to a runtime-written + # secret created by the container entrypoint from $HL_BENCH_LOCAL_AUTH. + password_file: /etc/prometheus/secrets/hl_bench_local_auth + + # OpenChainBench bench — current native-transfer transaction fee per L1 + # chain, in USD. Same 11-chain list as the L1 finality bench so users can + # compare time-to-finality and cost head-to-head. One process samples all + # 11 chains, polls Mobula every 30s for native-token USD prices, and + # exposes tx_fee_native_transfer_usd{chain,tier} plus per-chain gauges + # for the underlying native amount and gas price (EVM only). + - job_name: 'transaction-fee' + scrape_interval: 30s + scrape_timeout: 15s + static_configs: + - targets: + - 'transaction-fee.railway.internal:2112' + labels: + benchmark: network-fees + metrics_path: /metrics + + # OpenChainBench bench №032 - PM data freshness. Subscribes to the same + # basket of top-volume Polymarket markets on Polymarket CLOB (T0), + # Mobula Pulse PM, and Codex GraphQL simultaneously. Cross-correlates + # trades by (conditionId, price, 5s bucket) and reports per-provider ms + # lag versus the Polymarket gateway publish time. + - job_name: 'pm-freshness' + static_configs: + - targets: + - 'pm-freshness-bench-production.railway.internal:2112' + labels: + benchmark: pm-freshness + metrics_path: /metrics + + # OpenChainBench bench №037 - PM venue API rate limits + latency. Probes + # the public developer APIs of 5 prediction-market venues (Polymarket, + # Kalshi, Limitless, Manifold, Myriad): warm/cold latency on book, price + # and list classes, Polymarket public WS, plus a daily rate-limit ramp + # (one venue at a time, one region at a time on disjoint UTC hours). + # Three regions write the same pmapi_* metric family with a `region` + # label - honor_labels keeps each replica's region intact. + - job_name: 'pm-rate-limits' + honor_labels: true + static_configs: + - targets: + - 'pm-rate-limits-us.railway.internal:2112' + - 'pm-rate-limits-eu.railway.internal:2112' + - 'pm-rate-limits-sgp.railway.internal:2112' + labels: + benchmark: pm-rate-limits + metrics_path: /metrics + + # OpenChainBench bench - Polymarket resolution delay. Polls Gamma plus + # Polygon logs of the UMA CTF adapters (QuestionInitialized/Resolved, + # OO propose/dispute) and reports resolution delay anchored at the + # first onchain outcome proposal, dispute rate, and pending backlog. + # Single region service, pmres_* namespace. + - job_name: 'pm-resolution-delay' + static_configs: + - targets: + - 'pm-resolution-delay.railway.internal:2112' + labels: + benchmark: pm-resolution-delay + metrics_path: /metrics + + # OpenChainBench bench №033 - EVM swap quote latency. Hits the public + # /quote endpoint of Mobula + KyberSwap + Bebop + LI.FI + OpenOcean + # (plus 1inch / 0x / Odos once their free-tier API keys are added) + # every 60s on a rotating 5-pair basket across Ethereum, Base, Arbitrum + # and BSC. Three regions write the same metric family with a `region` + # label - honor_labels keeps each replica's region intact so the bench + # page filter on region works. + - job_name: 'evm-quote-latency' + honor_labels: true + static_configs: + - targets: + - 'evm-quote-latency-us.railway.internal:2112' + - 'evm-quote-latency-eu.railway.internal:2112' + - 'evm-quote-latency-sgp.railway.internal:2112' + labels: + benchmark: evm-quote-latency + metrics_path: /metrics + + # OpenChainBench bench №030 v3 — Hyperliquid historical archive. Reads + # the same upstream Hyperliquid CDN dumps as the v1 service but stores + # everything in a local DuckDB file so we can serve arbitrary date + # ranges (D90, D180, full-history cohort) via a small REST API in + # addition to the live Prom metrics. Runs a daily cron at 02:00 UTC to + # ingest the previous day's CSVs once the upstream bucket settles, then + # pushes a tiny snapshot to Upstash for the OCB site SSR. Single-region + # single-instance, persistent volume at /data for the DuckDB file. + - job_name: 'hl-archive' + scrape_interval: 30s + scrape_timeout: 15s + static_configs: + - targets: + - 'hl-archive.railway.internal:2114' + labels: + benchmark: hyperliquid-frontends + source: hl-archive + metrics_path: /metrics + + # OpenChainBench bench — live USD cost to create a fungible token on + # each supported chain, using the canonical method per chain (ERC20 + # deploy for EVM, SPL mint for Solana, TokenFactory denom for Cosmos, + # Move publish for Sui/Aptos, native asset/jetton for Cardano/Stellar). + # Read-only, no broadcast. 5-min cadence; bumped scrape_interval to 30s + # so Prom keeps up with the per-chain emit rate. + - job_name: 'token-deployment-cost' + scrape_interval: 30s + scrape_timeout: 15s + static_configs: + - targets: + - 'token-deployment-cost.railway.internal:2112' + labels: + benchmark: token-deployment-cost + metrics_path: /metrics diff --git a/infrastructure/monitoring/prometheus/recording_rules/ocb_evm_quote_latency.yml b/infrastructure/monitoring/prometheus/recording_rules/ocb_evm_quote_latency.yml new file mode 100644 index 00000000..7b611132 --- /dev/null +++ b/infrastructure/monitoring/prometheus/recording_rules/ocb_evm_quote_latency.yml @@ -0,0 +1,26 @@ +# Recording rules for OCB bench 033 (evm-quote-latency). +# +# Histogram bench: percentiles are recorded at full (provider, chain, +# region) granularity so the site's chain/region tab label-injection +# keeps working against one metric name. The site headline aggregates +# cells with avg(), i.e. equal-weight per cell (documented in the bench +# YAML formula text). Mean keeps exact ratio-of-sums semantics via the +# recorded sum/count rate pair. +groups: + - name: ocb_evm_quote_latency + interval: 60s + rules: + - record: ocb:evm_swap_quote_latency_ms:p50_24h + expr: histogram_quantile(0.50, sum by (provider, chain, region, le) (rate(evm_swap_quote_latency_ms_bucket[24h]))) + - record: ocb:evm_swap_quote_latency_ms:p90_24h + expr: histogram_quantile(0.90, sum by (provider, chain, region, le) (rate(evm_swap_quote_latency_ms_bucket[24h]))) + - record: ocb:evm_swap_quote_latency_ms:p99_24h + expr: histogram_quantile(0.99, sum by (provider, chain, region, le) (rate(evm_swap_quote_latency_ms_bucket[24h]))) + - record: ocb:evm_swap_quote_latency_ms:sum_rate_24h + expr: sum by (provider, chain, region) (rate(evm_swap_quote_latency_ms_sum[24h])) + - record: ocb:evm_swap_quote_latency_ms:count_rate_24h + expr: sum by (provider, chain, region) (rate(evm_swap_quote_latency_ms_count[24h])) + - record: ocb:evm_swap_quote_latency_ms:count_increase_24h + expr: sum by (provider, chain, region) (increase(evm_swap_quote_latency_ms_count[24h])) + - record: ocb:evm_swap_quote_success:avg_24h + expr: avg_over_time(evm_swap_quote_success[24h]) diff --git a/infrastructure/monitoring/prometheus/recording_rules/ocb_gas_estimation.yml b/infrastructure/monitoring/prometheus/recording_rules/ocb_gas_estimation.yml new file mode 100644 index 00000000..ddaa9951 --- /dev/null +++ b/infrastructure/monitoring/prometheus/recording_rules/ocb_gas_estimation.yml @@ -0,0 +1,14 @@ +# Recording rules for OCB bench 013 (gas-estimation). +# +# Covered rate: share of time each oracle's posted p50-tier prediction +# sat at or above the realized p50 priority fee of the latest mined +# block. Same-instant gauge comparison (a proxy for true per-block +# matching, pending a harness-side matched counter). Subquery at 1m +# steps over 24h; labels keep (oracle, chain, tier) so the site's +# chain tab label-injection works against the recorded metric. +groups: + - name: ocb_gas_estimation + interval: 60s + rules: + - record: ocb:gas_p50_covered:pct_24h + expr: 100 * avg_over_time((gas_predicted_priority_gwei{tier="p50"} >= bool on(chain, tier) group_left() gas_realized_priority_gwei{tier="p50"})[24h:1m]) diff --git a/infrastructure/monitoring/prometheus/recording_rules/ocb_l2_block_time.yml b/infrastructure/monitoring/prometheus/recording_rules/ocb_l2_block_time.yml new file mode 100644 index 00000000..59702c63 --- /dev/null +++ b/infrastructure/monitoring/prometheus/recording_rules/ocb_l2_block_time.yml @@ -0,0 +1,15 @@ +# Recording rules for OCB bench 009 (l2-block-time). +# +# Histogram bench: per-chain percentiles precomputed so the bench page +# reads cheap ocb:* series instead of evaluating histogram_quantile over +# the full 24h bucket window every render. +groups: + - name: ocb_l2_block_time + interval: 30s + rules: + - record: ocb:l2_block_time_milliseconds:p50_24h + expr: histogram_quantile(0.50, sum by (chain, le) (rate(l2_block_time_milliseconds_histogram_bucket[24h]))) + - record: ocb:l2_block_time_milliseconds:p90_24h + expr: histogram_quantile(0.90, sum by (chain, le) (rate(l2_block_time_milliseconds_histogram_bucket[24h]))) + - record: ocb:l2_block_time_milliseconds:p99_24h + expr: histogram_quantile(0.99, sum by (chain, le) (rate(l2_block_time_milliseconds_histogram_bucket[24h]))) diff --git a/infrastructure/monitoring/prometheus/recording_rules/ocb_pm_rate_limits.yml b/infrastructure/monitoring/prometheus/recording_rules/ocb_pm_rate_limits.yml new file mode 100644 index 00000000..6b8c98ee --- /dev/null +++ b/infrastructure/monitoring/prometheus/recording_rules/ocb_pm_rate_limits.yml @@ -0,0 +1,18 @@ +# Recording rules for OCB bench 037 (pm-rate-limits). +# +# Histogram bench: percentiles are recorded at full (venue, region, +# endpoint) granularity so the bench page can label-inject without +# fanning out across the ~1455 bucket series at query time. ttfb p50 +# included so the headline "first-byte" widget reads a single series. +groups: + - name: ocb_pm_rate_limits + interval: 30s + rules: + - record: ocb:pmapi_request_duration_seconds:p50_24h + expr: histogram_quantile(0.50, sum by (venue, region, endpoint, le) (rate(pmapi_request_duration_seconds_bucket[24h]))) + - record: ocb:pmapi_request_duration_seconds:p90_24h + expr: histogram_quantile(0.90, sum by (venue, region, endpoint, le) (rate(pmapi_request_duration_seconds_bucket[24h]))) + - record: ocb:pmapi_request_duration_seconds:p99_24h + expr: histogram_quantile(0.99, sum by (venue, region, endpoint, le) (rate(pmapi_request_duration_seconds_bucket[24h]))) + - record: ocb:pmapi_request_ttfb_seconds:p50_24h + expr: histogram_quantile(0.50, sum by (venue, region, endpoint, le) (rate(pmapi_request_ttfb_seconds_bucket[24h]))) diff --git a/infrastructure/monitoring/prometheus/recording_rules/ocb_rpc_capabilities.yml b/infrastructure/monitoring/prometheus/recording_rules/ocb_rpc_capabilities.yml new file mode 100644 index 00000000..625b6e53 --- /dev/null +++ b/infrastructure/monitoring/prometheus/recording_rules/ocb_rpc_capabilities.yml @@ -0,0 +1,32 @@ +# Recording rules for OCB bench 010 (rpc-capabilities). +# +# Why: the bench page used to evaluate quantile_over_time(...[24h]) over +# the raw gauge per provider per request. At Vercel cold start every +# bench fires at once and the burst of 24h-window quantiles browns out +# this Prom (providers time out -> partial leaderboards). These rules +# precompute the heavy aggregates once a minute; the site then reads +# them with cheap instant selectors (ocb:* series). +# +# Naming: ocb::_. Quantiles over the gauge +# are per-series (labels provider/chain/region/instance preserved); the +# site-side queries wrap them in avg()/sum by () as needed. Counter +# ratios are recorded as numerator/denominator pairs so the site can +# keep ratio-of-sums semantics. +groups: + - name: ocb_rpc_capabilities + interval: 60s + rules: + - record: ocb:rpc_latency_milliseconds:p50_24h + expr: quantile_over_time(0.50, rpc_latency_milliseconds[24h]) + - record: ocb:rpc_latency_milliseconds:p90_24h + expr: quantile_over_time(0.90, rpc_latency_milliseconds[24h]) + - record: ocb:rpc_latency_milliseconds:p99_24h + expr: quantile_over_time(0.99, rpc_latency_milliseconds[24h]) + - record: ocb:rpc_latency_milliseconds:mean_24h + expr: avg_over_time(rpc_latency_milliseconds[24h]) + - record: ocb:rpc_call:ok_rate_24h + expr: sum by (provider, chain, region) (rate(rpc_call_total{result="ok"}[24h])) + - record: ocb:rpc_call:rate_24h + expr: sum by (provider, chain, region) (rate(rpc_call_total[24h])) + - record: ocb:rpc_call:increase_24h + expr: sum by (provider, chain, region) (increase(rpc_call_total[24h])) diff --git a/infrastructure/monitoring/prometheus/recording_rules/ocb_solana_dex_quote_latency.yml b/infrastructure/monitoring/prometheus/recording_rules/ocb_solana_dex_quote_latency.yml new file mode 100644 index 00000000..ff08020f --- /dev/null +++ b/infrastructure/monitoring/prometheus/recording_rules/ocb_solana_dex_quote_latency.yml @@ -0,0 +1,22 @@ +# Recording rules for OCB bench 029 (solana-dex-quote-latency). +# +# Histogram bench, region-dimensioned only (Solana single chain). +# Same conventions as ocb_evm_quote_latency.yml. +groups: + - name: ocb_solana_dex_quote_latency + interval: 60s + rules: + - record: ocb:solana_quote_latency_ms:p50_24h + expr: histogram_quantile(0.50, sum by (provider, region, le) (rate(solana_quote_latency_ms_bucket[24h]))) + - record: ocb:solana_quote_latency_ms:p90_24h + expr: histogram_quantile(0.90, sum by (provider, region, le) (rate(solana_quote_latency_ms_bucket[24h]))) + - record: ocb:solana_quote_latency_ms:p99_24h + expr: histogram_quantile(0.99, sum by (provider, region, le) (rate(solana_quote_latency_ms_bucket[24h]))) + - record: ocb:solana_quote_latency_ms:sum_rate_24h + expr: sum by (provider, region) (rate(solana_quote_latency_ms_sum[24h])) + - record: ocb:solana_quote_latency_ms:count_rate_24h + expr: sum by (provider, region) (rate(solana_quote_latency_ms_count[24h])) + - record: ocb:solana_quote_latency_ms:count_increase_24h + expr: sum by (provider, region) (increase(solana_quote_latency_ms_count[24h])) + - record: ocb:solana_quote_success:avg_24h + expr: avg_over_time(solana_quote_success[24h]) diff --git a/infrastructure/prom-admin/.env.example b/infrastructure/prom-admin/.env.example new file mode 100644 index 00000000..7cd3697e --- /dev/null +++ b/infrastructure/prom-admin/.env.example @@ -0,0 +1,16 @@ +# Layer 2 — HTTP Basic Auth for the UI itself. Even if Cloudflare Access +# (Layer 1) is bypassed by misconfig, the app refuses without these. +ADMIN_USER= +ADMIN_PASS= + +# Layer 3 — Bearer the Caddy gateway expects to forward to Prom admin API. +# NEVER touches the browser. Set this to the same value as on the +# prom-gateway Railway service. +PROM_ADMIN_TOKEN= + +# Public URL of the Caddy gateway (NOT the Prometheus internal DNS). +# e.g. https://prom-gateway-production-xxxx.up.railway.app +PROM_GATEWAY_URL= + +# Optional kill-switch for the auto-bulk smart-clean endpoint. Default off. +SMART_CLEAN_ENABLED=false diff --git a/infrastructure/prom-admin/.gitignore b/infrastructure/prom-admin/.gitignore new file mode 100644 index 00000000..69f354c5 --- /dev/null +++ b/infrastructure/prom-admin/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.next/ +.env.local +.env diff --git a/infrastructure/prom-admin/Dockerfile b/infrastructure/prom-admin/Dockerfile new file mode 100644 index 00000000..27e3d340 --- /dev/null +++ b/infrastructure/prom-admin/Dockerfile @@ -0,0 +1,19 @@ +FROM node:22-alpine AS builder +WORKDIR /app +COPY package.json ./ +RUN npm install --no-audit --no-fund +COPY . . +RUN npm run build + +FROM node:22-alpine AS runtime +WORKDIR /app +ENV NODE_ENV=production +COPY --from=builder /app/package.json ./ +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/.next ./.next +COPY --from=builder /app/next.config.ts ./ +# Railway injects $PORT — Next 15 reads it automatically. +EXPOSE 3000 +# Call the next binary directly (npm run start fails to resolve `next` from +# PATH in some container contexts even when node_modules/.bin is populated). +CMD ["./node_modules/.bin/next", "start"] diff --git a/infrastructure/prom-admin/README.md b/infrastructure/prom-admin/README.md new file mode 100644 index 00000000..44a1232c --- /dev/null +++ b/infrastructure/prom-admin/README.md @@ -0,0 +1,173 @@ +# openchainbench-prom-admin + +A hardened admin UI to wipe Prometheus time-series data for a specific bench, behind 3 layers of auth. + +## What's here + +``` +infrastructure/prom-admin/ +├── app/ +│ ├── api/ +│ │ ├── chart/ read-only timeseries for the UI chart +│ │ ├── delete/ ★ DESTRUCTIVE — wipes a selector over a range +│ │ ├── labels/ lists dimension values for a metric +│ │ ├── metrics/ intersects Prom names with the allowlist +│ │ ├── preview/ read-only series-count for a planned delete +│ │ └── smart-clean/ disabled by default (env kill-switch) +│ ├── lib/ +│ │ ├── audit.ts stdout JSONL audit log +│ │ ├── prom.ts fetch wrappers with bearer for admin endpoints +│ │ ├── rate-limit.ts in-memory token bucket +│ │ └── validate.ts allowlist + label parsing + range guards +│ ├── health/ unauthenticated 200 for Railway healthcheck +│ ├── layout.tsx +│ └── page.tsx the UI +├── middleware.ts ★ HTTP Basic Auth on every route except /health +├── Dockerfile +├── railway.toml +└── package.json +``` + +## Architecture — 3 layers of auth + +``` + ┌────────────┐ Layer 1 (external) Cloudflare Access — email allowlist + │ Browser │ ─────────────────────► Configured in CF Zero Trust UI + └────────────┘ + │ + ▼ + ┌──────────────────────────┐ Layer 2 (app) middleware.ts — HTTP Basic Auth + │ openchainbench-prom-admin│ ──────────────► ADMIN_USER / ADMIN_PASS env vars + │ (this miniapp, on Railway)│ + └──────────────────────────┘ + │ + │ X-Admin-Token: $PROM_ADMIN_TOKEN (server-side fetch, never in browser) + ▼ + ┌──────────────┐ Layer 3 (network) Caddy gateway — bearer-token check + │ prom-gateway │ ───────────────────► routes /api/v1/admin/* + /-/reload + + │ (Caddy) │ /-/quit through the bearer; reads pass + └──────────────┘ through unauthenticated + │ + │ HTTP, Railway internal DNS only + ▼ + ┌──────────────┐ + │ prometheus │ binds 0.0.0.0:9090, exposed only over private DNS + └──────────────┘ +``` + +Each layer protects against a different failure mode: + +- **L1 leak** (CF Access misconfig) → app still asks for Basic Auth, attacker doesn't have the password +- **L2 leak** (Basic Auth password exfiltrated) → app sends bearer server-side only, attacker hitting Caddy directly without the bearer gets 403 +- **L3 leak** (bearer exfiltrated) → Prom only listens on internal DNS, attacker can't reach it from outside Railway +- **App XSS** → bearer never lands in the browser, only the Basic Auth cookie does. Attacker has app-level access but can't escalate to wipe. + +## Built-in safety constraints (besides auth) + +- **Metric allowlist** — only `head_lag_*`, `l1_finality_*`, `l2_block_time_*`, `solana_quote_*`, etc. (see `app/lib/validate.ts`). Reject anything else, including `__name__=~".+"` wildcard tricks. +- **Label parser** — `key="value"` pairs parsed strictly; anything that doesn't match the schema is rejected. No raw PromQL interpolation = no selector injection. +- **Range guards** — max 7 days per delete window, max 90 days into the past, end ≤ now. +- **Series cap** — preview every delete before executing; refuse if matches > 5,000 series. +- **Typed-slug confirmation** — UI must echo back `delete--` before delete fires. +- **Rate limit** — 5 destructive actions / hour / user, 20 reads / minute / user (per-user, in-memory). +- **Audit log** — every preview, delete, denial logged as JSON to stdout. Tagged `[AUDIT]`. Railway captures stdout, push to Datadog later for retention. +- **Smart-clean disabled** — the bulk auto-wipe endpoint is off by default; set `SMART_CLEAN_ENABLED=true` only when explicitly needed. + +## Deploy guide (Railway, noob-friendly) + +### Prerequisites + +- Railway project with the existing `prometheus` service running +- A Caddy gateway service in front of Prom (see `../openchainbench-monitoring/prom-gateway/README.md`) + +### Step 1 — generate secrets + +On your laptop: + +```bash +# Layer 2 Basic Auth password +ADMIN_PASS=$(openssl rand -base64 24) +echo "ADMIN_PASS=$ADMIN_PASS" + +# Layer 3 bearer (same value you'll set on the prom-gateway service) +PROM_ADMIN_TOKEN=$(openssl rand -hex 32) +echo "PROM_ADMIN_TOKEN=$PROM_ADMIN_TOKEN" +``` + +Save both in 1Password (vault `mobula-engineering`, item `OCB Prom admin secrets`). + +### Step 2 — set the bearer on the Caddy gateway + +Open the `prom-gateway` Railway service → **Variables** tab → add `PROM_ADMIN_TOKEN` with the value from Step 1. Redeploy. + +### Step 3 — create the admin UI service on Railway + +1. Same Railway project → **+ New → Empty Service**. Rename `openchainbench-prom-admin`. +2. **Settings → Source**: connect the `ChainBench/OpenChainBench` repo, set **Root Directory** to `infrastructure/prom-admin`. +3. **Variables** tab, add: + - `ADMIN_USER` = `florent` (or whatever) + - `ADMIN_PASS` = (value from Step 1) + - `PROM_ADMIN_TOKEN` = (same value as on the gateway service) + - `PROM_GATEWAY_URL` = public URL of the gateway, e.g. `https://prom-gateway-production-xxxx.up.railway.app` +4. **Settings → Networking → Generate Domain**. Note the URL. +5. Wait for the build to finish, then open the URL — you should get a Basic Auth prompt. Enter `ADMIN_USER` / `ADMIN_PASS`. UI loads. + +### Step 4 — Cloudflare Access (Layer 1) + +Optional but recommended. + +1. Add your Railway domain to Cloudflare DNS (Cloudflare proxy ON). +2. Cloudflare → **Zero Trust → Access → Applications → Add an application**. + - Type: **Self-hosted** + - Subdomain: the one Railway gave you (or your custom one) +3. **Access policies** → Add a rule "team allowlist": + - Action: **Allow** + - Include: **Emails** → list your team emails (e.g. `contact@mobula.io`, `florent@mobula.io`) +4. **Authentication** → enable **Google** or **GitHub** as the identity provider (one-click in CF UI; both free). +5. Save. The Railway URL now redirects to a CF login page; only allowlisted emails get through. After CF auth, the Basic Auth prompt fires too. + +### Step 5 — smoke test + +```bash +# Unauthenticated → 401 +curl -i https://openchainbench-prom-admin-production-xxxx.up.railway.app/api/metrics + +# Authenticated, valid → 200, list of allowed metrics +curl -u "$ADMIN_USER:$ADMIN_PASS" \ + https://openchainbench-prom-admin-production-xxxx.up.railway.app/api/metrics + +# Try to delete with no confirm token → 400 confirm_mismatch +curl -u "$ADMIN_USER:$ADMIN_PASS" -X POST \ + -H 'content-type: application/json' \ + -d '{"metric":"head_lag_seconds","startTime":"2026-06-01T10:00","endTime":"2026-06-01T10:30","confirm":"wrong"}' \ + https://openchainbench-prom-admin-production-xxxx.up.railway.app/api/delete +``` + +### Step 6 — use the UI + +Open the URL in your browser. CF Access prompts for login (if Layer 1 enabled). Basic Auth prompts (Layer 2). UI loads: + +1. Pick a metric from the dropdown +2. Optionally enter `labels` (e.g. `chain="solana"`) +3. Pick start + end datetime +4. Click **Preview** → shows how many series will be wiped +5. Click **Delete…** → modal asks for the confirmation token, copy-paste from the highlighted code, click **Confirm delete** +6. UI shows `deleted` with a request_id; audit log entry is in Railway logs as `[AUDIT] {...}` + +## Local development + +```bash +cp .env.example .env.local +# fill in the values +npm install +npm run dev +# UI at http://localhost:3000 +``` + +The Caddy gateway must be reachable from your laptop for the delete/preview to work end-to-end. For local-only testing, you can point `PROM_GATEWAY_URL` at a Caddy instance running on `localhost:8080`. + +## Operational notes + +- **Audit log queries**: in Datadog, filter `service:openchainbench-prom-admin @message:"[AUDIT]"` to see every action. +- **Rotating secrets**: change `ADMIN_PASS` and `PROM_ADMIN_TOKEN` on both services (gateway + admin-ui) on the same day. UI sessions invalidate next refresh; gateway picks up the new token on redeploy. +- **Allowlist edits**: append to `ALLOWED_METRIC_PREFIXES` in `app/lib/validate.ts`, commit, redeploy. Do NOT make the allowlist env-driven — keeping it in code means a malicious env var injection can't widen the blast radius. diff --git a/infrastructure/prom-admin/app/api/chart/route.ts b/infrastructure/prom-admin/app/api/chart/route.ts new file mode 100644 index 00000000..b906c593 --- /dev/null +++ b/infrastructure/prom-admin/app/api/chart/route.ts @@ -0,0 +1,67 @@ +// POST /api/chart — returns timeseries in the shape the original recharts UI +// expects: { data: [{ timestamp_ms, value, label }, ...] }. +// All series are flattened together; the chart Line dataKey="value" plots +// every row regardless of label, which is how the original delete-ui showed +// "all regions/chains overlaid" without per-series logic. +// +// Hardened: metric allowlist + label parser + hours bound + read rate limit. + +import { NextRequest, NextResponse } from "next/server"; +import { promRead } from "@/app/lib/prom"; +import { allowRead } from "@/app/lib/rate-limit"; +import { buildMatcher, isAllowedMetric, parseLabels } from "@/app/lib/validate"; + +export async function POST(req: NextRequest) { + const user = req.headers.get("x-admin-user") ?? "unknown"; + if (!allowRead(user)) return NextResponse.json({ error: "rate limit" }, { status: 429 }); + + const body = await req.json().catch(() => null); + if (!body || typeof body !== "object") { + return NextResponse.json({ error: "invalid body" }, { status: 400 }); + } + const { metric, labels, hours } = body as { metric?: string; labels?: string; hours?: number }; + + if (!metric || !isAllowedMetric(metric)) { + return NextResponse.json({ error: `metric "${metric}" not allowed` }, { status: 400 }); + } + const h = typeof hours === "number" ? hours : Number.parseFloat(String(hours ?? "24")); + if (!Number.isFinite(h) || h <= 0 || h > 168) { + return NextResponse.json({ error: "hours must be 0 < h <= 168" }, { status: 400 }); + } + let pairs; + try { + pairs = parseLabels(labels ?? ""); + } catch (e: unknown) { + return NextResponse.json( + { error: e instanceof Error ? e.message : "labels invalid" }, + { status: 400 }, + ); + } + + const matcher = buildMatcher(metric, pairs); + const endTs = Math.floor(Date.now() / 1000); + const startTs = endTs - Math.round(h * 3600); + const q = `avg_over_time(${matcher}[1m])`; + const url = `/api/v1/query_range?query=${encodeURIComponent(q)}&start=${startTs}&end=${endTs}&step=60`; + const r = await promRead(url); + const j = (await r.json()) as { + data?: { result?: Array<{ metric: Record; values: Array<[number, string]> }> }; + }; + + const chartData: Array<{ timestamp: number; value: number; label: string }> = []; + for (const s of j.data?.result ?? []) { + const seriesLabel = Object.entries(s.metric) + .filter(([k]) => !k.startsWith("__")) + .map(([k, v]) => `${k}=${v}`) + .join(", "); + for (const [ts, v] of s.values) { + chartData.push({ + timestamp: ts * 1000, + value: Number.parseFloat(v), + label: seriesLabel, + }); + } + } + chartData.sort((a, b) => a.timestamp - b.timestamp); + return NextResponse.json({ data: chartData }); +} diff --git a/infrastructure/prom-admin/app/api/delete/route.ts b/infrastructure/prom-admin/app/api/delete/route.ts new file mode 100644 index 00000000..e6542712 --- /dev/null +++ b/infrastructure/prom-admin/app/api/delete/route.ts @@ -0,0 +1,188 @@ +// POST /api/delete — wipe a time range for a metric+labels selector. +// +// Security stack: +// - Basic Auth middleware in front (only authenticated users reach here) +// - Metric allowlist (server-side, no env override possible) +// - Strict label parser (no PromQL injection) +// - Range bound (max MAX_RANGE_HOURS window, max MAX_AGE_DAYS old) +// - Rate limit (5 destructive actions/h/user) +// - Audit log on every outcome (preview / delete / denied / error) +// +// The browser-side native confirm() at the UI level is the user-confirmation +// step; the typed-slug was overkill behind Basic Auth + Cloudflare Access. + +import { NextRequest, NextResponse } from "next/server"; +import { promAdmin, promRead } from "@/app/lib/prom"; +import { audit, newRequestId } from "@/app/lib/audit"; +import { allowDestructive } from "@/app/lib/rate-limit"; +import { + buildMatcher, + isAllowedMetric, + parseLabels, + validateRange, +} from "@/app/lib/validate"; + +export async function POST(req: NextRequest) { + const user = req.headers.get("x-admin-user") ?? "unknown"; + const requestId = newRequestId(); + + const body = await req.json().catch(() => null); + if (!body || typeof body !== "object") { + return NextResponse.json({ error: "invalid body" }, { status: 400 }); + } + const { metric, labels, startTime, endTime, threshold, thresholdOp } = body as { + metric?: string; + labels?: string; + startTime?: string; + endTime?: string; + threshold?: number; + thresholdOp?: string; + }; + + const baseAudit = { + user, + action: "delete" as const, + metric: metric ?? "", + labels: labels ?? "", + start_ts: 0, + end_ts: 0, + request_id: requestId, + }; + + if (!allowDestructive(user)) { + audit({ ...baseAudit, result: "denied", reason: "rate_limit" }); + return NextResponse.json( + { error: "rate limit (5 destructive actions/hour)" }, + { status: 429 }, + ); + } + if (!metric || !isAllowedMetric(metric)) { + audit({ ...baseAudit, result: "denied", reason: "metric_not_allowed" }); + return NextResponse.json({ error: `metric "${metric}" is not allowed` }, { status: 400 }); + } + if (!startTime || !endTime) { + audit({ ...baseAudit, result: "denied", reason: "missing_range" }); + return NextResponse.json({ error: "startTime and endTime required" }, { status: 400 }); + } + + let range; + try { + range = validateRange(startTime, endTime); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : "range invalid"; + audit({ ...baseAudit, result: "denied", reason: msg }); + return NextResponse.json({ error: msg }, { status: 400 }); + } + baseAudit.start_ts = range.startTs; + baseAudit.end_ts = range.endTs; + + let pairs; + try { + pairs = parseLabels(labels ?? ""); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : "labels invalid"; + audit({ ...baseAudit, result: "denied", reason: msg }); + return NextResponse.json({ error: msg }, { status: 400 }); + } + + const matcher = buildMatcher(metric, pairs); + + // Threshold-based "smart range" delete: find sample points above the + // threshold inside the range, group them into spikes (>120s gap = new + // spike), and delete each spike window (-30/+30s margin). This is the + // behavior the original delete-ui shipped — kept here under the same + // allowlist + rate-limit + audit guardrails. + if (threshold != null && thresholdOp) { + if (!/^(>|<|>=|<=|==|!=)$/.test(thresholdOp)) { + audit({ ...baseAudit, result: "denied", reason: "bad_thresholdOp" }); + return NextResponse.json({ error: "thresholdOp invalid" }, { status: 400 }); + } + const t = Number(threshold); + if (!Number.isFinite(t)) { + audit({ ...baseAudit, result: "denied", reason: "bad_threshold" }); + return NextResponse.json({ error: "threshold must be a number" }, { status: 400 }); + } + const q = `${matcher} ${thresholdOp} ${t}`; + const qURL = `/api/v1/query_range?query=${encodeURIComponent(q)}&start=${range.startTs}&end=${range.endTs}&step=15`; + const qRes = await promRead(qURL); + const qJSON = (await qRes.json()) as { + data?: { result?: Array<{ values: Array<[number, string]> }> }; + }; + const spikes: Array<{ start: number; end: number }> = []; + for (const s of qJSON.data?.result ?? []) { + let spikeStart: number | null = null; + let lastTs: number | null = null; + for (const [ts] of s.values) { + if (spikeStart === null) { + spikeStart = ts; + lastTs = ts; + } else if (lastTs && ts - lastTs > 120) { + spikes.push({ start: spikeStart - 30, end: lastTs + 30 }); + spikeStart = ts; + } + lastTs = ts; + } + if (spikeStart != null && lastTs != null) { + spikes.push({ start: spikeStart - 30, end: lastTs + 30 }); + } + } + let deleted = 0; + for (const sp of spikes) { + const p = new URLSearchParams(); + p.set("match[]", matcher); + p.set("start", String(Math.floor(sp.start))); + p.set("end", String(Math.ceil(sp.end))); + const r = await promAdmin(`/api/v1/admin/tsdb/delete_series?${p.toString()}`, { + method: "POST", + }); + if (r.status === 204) deleted++; + } + await promAdmin("/api/v1/admin/tsdb/clean_tombstones", { method: "POST" }); + audit({ + ...baseAudit, + groups: spikes.length, + result: "ok", + reason: `threshold ${thresholdOp} ${threshold}`, + }); + return NextResponse.json({ + message: `Deleted ${deleted} spike(s) (over ${spikes.length} detected) from ${metric}`, + deleted, + groups: spikes.length, + request_id: requestId, + }); + } + + // Normal whole-range delete with ±60s margin (matches original behavior). + const expandedStart = range.startTs - 60; + const expandedEnd = range.endTs + 60; + const params = new URLSearchParams(); + params.set("match[]", matcher); + params.set("start", String(expandedStart)); + params.set("end", String(expandedEnd)); + + const delRes = await promAdmin(`/api/v1/admin/tsdb/delete_series?${params.toString()}`, { + method: "POST", + }); + if (delRes.status !== 204) { + const text = await delRes.text(); + audit({ + ...baseAudit, + result: "error", + reason: `delete_series http_${delRes.status}`, + }); + return NextResponse.json( + { error: `delete_series failed: ${delRes.status}`, body: text.slice(0, 500) }, + { status: 502 }, + ); + } + const cleanRes = await promAdmin("/api/v1/admin/tsdb/clean_tombstones", { method: "POST" }); + const cleanOk = cleanRes.status === 204; + + audit({ ...baseAudit, result: "ok" }); + return NextResponse.json({ + message: `Successfully deleted ${metric} from ${new Date(startTime).toLocaleString()} to ${new Date(endTime).toLocaleString()}`, + matcher, + clean_tombstones: cleanOk, + request_id: requestId, + }); +} diff --git a/infrastructure/prom-admin/app/api/labels/route.ts b/infrastructure/prom-admin/app/api/labels/route.ts new file mode 100644 index 00000000..99dc47c5 --- /dev/null +++ b/infrastructure/prom-admin/app/api/labels/route.ts @@ -0,0 +1,28 @@ +// GET /api/labels — returns the dimension values for head_lag_seconds{aggregator="mobula"} +// in the shape the original delete-ui expects: { chains: ['All', ...], regions: ['All', ...] }. +// +// Reuses promRead (no bearer needed). Rate-limited. + +import { NextRequest, NextResponse } from "next/server"; +import { promRead } from "@/app/lib/prom"; +import { allowRead } from "@/app/lib/rate-limit"; + +export async function GET(req: NextRequest) { + const user = req.headers.get("x-admin-user") ?? "unknown"; + if (!allowRead(user)) return NextResponse.json({ error: "rate limit" }, { status: 429 }); + + const r = await promRead('/api/v1/query?query=head_lag_seconds{aggregator="mobula"}'); + const j = (await r.json()) as { + data?: { result?: Array<{ metric: Record }> }; + }; + const chains = new Set(); + const regions = new Set(); + for (const s of j.data?.result ?? []) { + if (s.metric.chain) chains.add(s.metric.chain); + if (s.metric.region) regions.add(s.metric.region); + } + return NextResponse.json({ + chains: ["All", ...Array.from(chains).sort()], + regions: ["All", ...Array.from(regions).sort()], + }); +} diff --git a/infrastructure/prom-admin/app/api/metrics/route.ts b/infrastructure/prom-admin/app/api/metrics/route.ts new file mode 100644 index 00000000..2309ffbb --- /dev/null +++ b/infrastructure/prom-admin/app/api/metrics/route.ts @@ -0,0 +1,19 @@ +// GET /api/metrics — returns the allowlisted metric names that exist in Prom. +// Intersects the server-side allowlist with Prom's `__name__` index so the +// UI never sees a metric it's not allowed to delete. + +import { NextRequest, NextResponse } from "next/server"; +import { promRead } from "@/app/lib/prom"; +import { allowRead } from "@/app/lib/rate-limit"; +import { isAllowedMetric } from "@/app/lib/validate"; + +export async function GET(req: NextRequest) { + const user = req.headers.get("x-admin-user") ?? "unknown"; + if (!allowRead(user)) { + return NextResponse.json({ error: "rate limit" }, { status: 429 }); + } + const r = await promRead("/api/v1/label/__name__/values"); + const j = (await r.json()) as { data?: string[] }; + const allowed = (j.data ?? []).filter(isAllowedMetric).sort(); + return NextResponse.json({ ok: true, metrics: allowed }); +} diff --git a/infrastructure/prom-admin/app/api/preview/route.ts b/infrastructure/prom-admin/app/api/preview/route.ts new file mode 100644 index 00000000..f9de8cad --- /dev/null +++ b/infrastructure/prom-admin/app/api/preview/route.ts @@ -0,0 +1,91 @@ +// POST /api/preview — read-only count of how many samples a selector would +// touch. Used by the UI to populate "this will delete N points" before the +// destructive confirmation. No bearer needed (uses promRead, not promAdmin). + +import { NextRequest, NextResponse } from "next/server"; +import { promRead } from "@/app/lib/prom"; +import { audit, newRequestId } from "@/app/lib/audit"; +import { allowRead } from "@/app/lib/rate-limit"; +import { + buildMatcher, + isAllowedMetric, + parseLabels, + serializeLabels, + validateRange, +} from "@/app/lib/validate"; + +export async function POST(req: NextRequest) { + const user = req.headers.get("x-admin-user") ?? "unknown"; + const requestId = newRequestId(); + + if (!allowRead(user)) { + return NextResponse.json({ error: "rate limit (20 reads/min)" }, { status: 429 }); + } + + const body = await req.json().catch(() => null); + if (!body || typeof body !== "object") { + return NextResponse.json({ error: "invalid body" }, { status: 400 }); + } + const { metric, labels, startTime, endTime } = body as { + metric?: string; + labels?: string; + startTime?: string; + endTime?: string; + }; + + if (!metric || !isAllowedMetric(metric)) { + return NextResponse.json({ error: `metric "${metric}" is not allowed` }, { status: 400 }); + } + if (!startTime || !endTime) { + return NextResponse.json({ error: "startTime and endTime required" }, { status: 400 }); + } + let range: ReturnType; + try { + range = validateRange(startTime, endTime); + } catch (e: unknown) { + return NextResponse.json( + { error: e instanceof Error ? e.message : "range invalid" }, + { status: 400 }, + ); + } + let pairs; + try { + pairs = parseLabels(labels ?? ""); + } catch (e: unknown) { + return NextResponse.json( + { error: e instanceof Error ? e.message : "labels invalid" }, + { status: 400 }, + ); + } + + const matcher = buildMatcher(metric, pairs); + const window = range.endTs - range.startTs; + const q = `count(count_over_time(${matcher}[${window}s] @ ${range.endTs}))`; + const url = `/api/v1/query?query=${encodeURIComponent(q)}`; + const r = await promRead(url); + const j = (await r.json()) as { data?: { result?: Array<{ value?: [number, string] }> } }; + const seriesCount = j.data?.result?.[0]?.value?.[1] + ? Number.parseInt(j.data.result[0].value[1], 10) + : 0; + + audit({ + user, + action: "preview", + metric, + labels: labels ?? "", + start_ts: range.startTs, + end_ts: range.endTs, + series_count: seriesCount, + result: "ok", + request_id: requestId, + }); + + return NextResponse.json({ + ok: true, + matcher, + labels_normalized: serializeLabels(pairs), + series_count: seriesCount, + start_ts: range.startTs, + end_ts: range.endTs, + }); +} diff --git a/infrastructure/prom-admin/app/api/smart-clean/route.ts b/infrastructure/prom-admin/app/api/smart-clean/route.ts new file mode 100644 index 00000000..c8f189e2 --- /dev/null +++ b/infrastructure/prom-admin/app/api/smart-clean/route.ts @@ -0,0 +1,217 @@ +// POST /api/smart-clean — original delete-ui auto-bulk wipe for +// head_lag_seconds{aggregator="mobula"}. Scans the last N hours for points +// over a threshold, groups consecutive ones (>120s gap = new group), and +// bulk-deletes each group with ±180s margin. +// +// Security stack (same as /api/delete): +// - Basic Auth middleware gates this route +// - Hardcoded metric = head_lag_seconds (no user-supplied selector) +// - hours capped at 168h, threshold must be finite, max 50 groups per run +// - Rate-limited on the destructive bucket (5/h/user) +// - Bearer for Caddy admin API attached server-side only +// - Every action audited to stdout + +import { NextRequest, NextResponse } from "next/server"; +import { promAdmin, promRead } from "@/app/lib/prom"; +import { audit, newRequestId } from "@/app/lib/audit"; +import { allowDestructive, allowRead } from "@/app/lib/rate-limit"; + +const MAX_HOURS = 168; +const MAX_GROUPS_PER_RUN = 500; + +type Spike = { timestamp: number; value: number; region: string; chain: string }; +type SpikeGroup = { + region: string; + chain: string; + startTs: number; + endTs: number; + duration: number; +}; + +export async function POST(req: NextRequest) { + const user = req.headers.get("x-admin-user") ?? "unknown"; + const requestId = newRequestId(); + + const body = await req.json().catch(() => null); + if (!body || typeof body !== "object") { + return NextResponse.json({ error: "invalid body" }, { status: 400 }); + } + const { threshold = 5, hours = 24, dryRun = false } = body as { + threshold?: number; + hours?: number; + dryRun?: boolean; + }; + + const t = Number(threshold); + const h = Number(hours); + if (!Number.isFinite(t) || t <= 0) { + return NextResponse.json({ error: "threshold must be > 0" }, { status: 400 }); + } + if (!Number.isFinite(h) || h <= 0 || h > MAX_HOURS) { + return NextResponse.json({ error: `hours must be 0 < h <= ${MAX_HOURS}` }, { status: 400 }); + } + + const baseAudit = { + user, + action: dryRun ? ("smart-clean-dry-run" as const) : ("smart-clean" as const), + metric: "head_lag_seconds", + labels: `aggregator="mobula"`, + start_ts: 0, + end_ts: 0, + request_id: requestId, + }; + + // Read bucket for dry-run, destructive bucket for the real wipe. + if (dryRun) { + if (!allowRead(user)) { + audit({ ...baseAudit, result: "denied", reason: "rate_limit" }); + return NextResponse.json({ error: "rate limit" }, { status: 429 }); + } + } else if (!allowDestructive(user)) { + audit({ ...baseAudit, result: "denied", reason: "rate_limit" }); + return NextResponse.json( + { error: "rate limit (5 destructive actions/hour)" }, + { status: 429 }, + ); + } + + const endTs = Math.floor(Date.now() / 1000); + const startTs = endTs - Math.round(h * 3600); + baseAudit.start_ts = startTs; + baseAudit.end_ts = endTs; + + const query = 'head_lag_seconds{aggregator="mobula"}'; + // Adaptive step: keep raw resolution on short windows, coarsen for long ones + // so we don't blow past Prom's max-samples limit (default 50M but tunable). + const step = h <= 24 ? 15 : h <= 72 ? 30 : 60; + const queryUrl = `/api/v1/query_range?query=${encodeURIComponent(query)}&start=${startTs}&end=${endTs}&step=${step}`; + const qRes = await promRead(queryUrl); + if (!qRes.ok) { + const txt = await qRes.text().catch(() => ""); + audit({ ...baseAudit, result: "error", reason: `prom_${qRes.status}` }); + return NextResponse.json( + { error: `prometheus query_range failed (${qRes.status}): ${txt.slice(0, 300)}` }, + { status: 502 }, + ); + } + const data = (await qRes.json()) as { + status?: string; + error?: string; + data?: { result?: Array<{ metric: Record; values: Array<[number, string]> }> }; + }; + if (data.status === "error") { + audit({ ...baseAudit, result: "error", reason: `prom_error ${data.error ?? ""}` }); + return NextResponse.json( + { error: `prometheus error: ${data.error ?? "unknown"}` }, + { status: 502 }, + ); + } + + // Filter spikes over the threshold + const rawSpikes: Spike[] = []; + for (const s of data.data?.result ?? []) { + const region = s.metric.region || "unknown"; + const chain = s.metric.chain || "unknown"; + for (const [timestamp, value] of s.values) { + const v = Number.parseFloat(value); + if (v > t) rawSpikes.push({ timestamp: Number(timestamp), value: v, region, chain }); + } + } + rawSpikes.sort((a, b) => { + if (a.region !== b.region) return a.region.localeCompare(b.region); + if (a.chain !== b.chain) return a.chain.localeCompare(b.chain); + return a.timestamp - b.timestamp; + }); + + // Group consecutive spikes per (region, chain). Gap > 120s = new group. + const groups: SpikeGroup[] = []; + let current: SpikeGroup | null = null; + for (const sp of rawSpikes) { + if ( + !current || + current.region !== sp.region || + current.chain !== sp.chain || + sp.timestamp - current.endTs > 120 + ) { + if (current) groups.push(current); + current = { region: sp.region, chain: sp.chain, startTs: sp.timestamp, endTs: sp.timestamp, duration: 0 }; + } else { + current.endTs = sp.timestamp; + } + } + if (current) groups.push(current); + for (const g of groups) g.duration = g.endTs - g.startTs; + + // Hard cap — refuse runs that would touch huge swaths of data. + if (groups.length > MAX_GROUPS_PER_RUN) { + audit({ + ...baseAudit, + groups: groups.length, + result: "denied", + reason: `too_many_groups ${groups.length} > ${MAX_GROUPS_PER_RUN}`, + }); + return NextResponse.json( + { + error: `${groups.length} groups detected, max ${MAX_GROUPS_PER_RUN}/run. Run a dry-run, tighten the threshold or shrink hours.`, + }, + { status: 400 }, + ); + } + + if (dryRun) { + audit({ ...baseAudit, groups: groups.length, result: "ok", reason: "dry_run" }); + return NextResponse.json({ + dryRun: true, + totalSpikes: rawSpikes.length, + totalGroups: groups.length, + groups: groups.map((g) => ({ + region: g.region, + chain: g.chain, + startTime: new Date(g.startTs * 1000).toISOString(), + endTime: new Date(g.endTs * 1000).toISOString(), + duration: g.duration, + })), + }); + } + + // Execute deletes (±180s margin per group). + let deletedCount = 0; + let failedCount = 0; + for (const g of groups) { + const expStart = g.startTs - 180; + const expEnd = g.endTs + 180; + const match = `head_lag_seconds{aggregator="mobula",region="${g.region}",chain="${g.chain}"}`; + const params = new URLSearchParams(); + params.set("match[]", match); + params.set("start", String(expStart)); + params.set("end", String(expEnd)); + const r = await promAdmin(`/api/v1/admin/tsdb/delete_series?${params.toString()}`, { + method: "POST", + }); + if (r.status === 204) deletedCount++; + else failedCount++; + } + await promAdmin("/api/v1/admin/tsdb/clean_tombstones", { method: "POST" }); + + audit({ + ...baseAudit, + groups: groups.length, + result: failedCount > 0 ? "error" : "ok", + reason: failedCount > 0 ? `${failedCount} groups failed` : undefined, + }); + return NextResponse.json({ + dryRun: false, + totalSpikes: rawSpikes.length, + totalGroups: groups.length, + deleted: deletedCount, + failed: failedCount, + groups: groups.map((g) => ({ + region: g.region, + chain: g.chain, + startTime: new Date(g.startTs * 1000).toISOString(), + endTime: new Date(g.endTs * 1000).toISOString(), + duration: g.duration, + })), + request_id: requestId, + }); +} diff --git a/infrastructure/prom-admin/app/health/route.ts b/infrastructure/prom-admin/app/health/route.ts new file mode 100644 index 00000000..3499ff18 --- /dev/null +++ b/infrastructure/prom-admin/app/health/route.ts @@ -0,0 +1,5 @@ +import { NextResponse } from 'next/server'; + +export async function GET() { + return NextResponse.json({ status: 'ok' }); +} \ No newline at end of file diff --git a/infrastructure/prom-admin/app/layout.tsx b/infrastructure/prom-admin/app/layout.tsx new file mode 100644 index 00000000..5d515395 --- /dev/null +++ b/infrastructure/prom-admin/app/layout.tsx @@ -0,0 +1,13 @@ +export const metadata = { + title: "OCB Prom Admin", + description: "Bounded delete + audit for the OpenChainBench Prometheus", + robots: "noindex,nofollow", +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/infrastructure/prom-admin/app/lib/audit.ts b/infrastructure/prom-admin/app/lib/audit.ts new file mode 100644 index 00000000..87421dcf --- /dev/null +++ b/infrastructure/prom-admin/app/lib/audit.ts @@ -0,0 +1,37 @@ +// Audit log. +// +// v1 = append-only JSONL to stdout (Railway captures all stdout, queryable +// via Datadog later). Every destructive action MUST call audit() before +// returning. +// +// Each entry has user (from middleware), action, metric, labels, range, +// series_count (when known), result, request_id. Fields are flat for easy +// log parsing. + +export type AuditEvent = { + ts: string; // ISO8601 + user: string; // from middleware + action: "preview" | "delete" | "smart-clean" | "smart-clean-dry-run"; + metric: string; + labels: string; + start_ts: number; + end_ts: number; + series_count?: number; + groups?: number; + result: "ok" | "denied" | "error"; + reason?: string; // present on denied / error + request_id: string; +}; + +export function audit(ev: Omit) { + const full: AuditEvent = { ts: new Date().toISOString(), ...ev }; + // Single-line JSON, prefixed with [AUDIT] so it's grep-friendly in Railway logs. + console.log(`[AUDIT] ${JSON.stringify(full)}`); +} + +export function newRequestId(): string { + // 8 random hex chars — collision unlikely at the volume this thing sees. + const bytes = new Uint8Array(8); + crypto.getRandomValues(bytes); + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); +} diff --git a/infrastructure/prom-admin/app/lib/prom.ts b/infrastructure/prom-admin/app/lib/prom.ts new file mode 100644 index 00000000..d3fca055 --- /dev/null +++ b/infrastructure/prom-admin/app/lib/prom.ts @@ -0,0 +1,55 @@ +// Helpers to talk to the OCB Prometheus. +// +// READ queries hit the gateway with NO bearer (publicly readable — Vercel +// reads the same paths). ADMIN endpoints (`/admin/tsdb/*`, `/-/reload`, etc.) +// require the bearer the Caddy gateway expects. + +const READ_TIMEOUT_MS = 15_000; +const ADMIN_TIMEOUT_MS = 30_000; + +function requireEnv(name: string): string { + const v = process.env[name]; + if (!v || v.trim() === "") { + throw new Error(`missing env: ${name}`); + } + return v; +} + +export function promGatewayUrl(): string { + return requireEnv("PROM_GATEWAY_URL"); +} + +function adminToken(): string { + return requireEnv("PROM_ADMIN_TOKEN"); +} + +export async function promRead(path: string, init?: RequestInit): Promise { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), READ_TIMEOUT_MS); + try { + return await fetch(`${promGatewayUrl()}${path}`, { + ...init, + signal: ctrl.signal, + cache: "no-store", + }); + } finally { + clearTimeout(t); + } +} + +export async function promAdmin(path: string, init?: RequestInit): Promise { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), ADMIN_TIMEOUT_MS); + try { + const headers = new Headers(init?.headers); + headers.set("X-Admin-Token", adminToken()); + return await fetch(`${promGatewayUrl()}${path}`, { + ...init, + headers, + signal: ctrl.signal, + cache: "no-store", + }); + } finally { + clearTimeout(t); + } +} diff --git a/infrastructure/prom-admin/app/lib/rate-limit.ts b/infrastructure/prom-admin/app/lib/rate-limit.ts new file mode 100644 index 00000000..ddabfe17 --- /dev/null +++ b/infrastructure/prom-admin/app/lib/rate-limit.ts @@ -0,0 +1,42 @@ +// In-memory token bucket rate-limiter, per-user. +// +// Lives in the Node.js module scope of one Railway instance. With a single +// instance (which is what we run), this is correct. If we ever scale +// horizontally, switch to Redis-backed. +// +// Defaults: 20 reads / min / user, 20 destructive actions / hour / user. + +type Bucket = { tokens: number; lastRefill: number }; + +const READ_CAP = 20; +const READ_PER_MIN = 20; +const DESTRUCTIVE_CAP = 20; +const DESTRUCTIVE_PER_HOUR = 20; + +const readBuckets = new Map(); +const destructiveBuckets = new Map(); + +function take(map: Map, key: string, cap: number, refillPerSec: number): boolean { + const now = Date.now() / 1000; + let b = map.get(key); + if (!b) { + b = { tokens: cap, lastRefill: now }; + map.set(key, b); + } + const elapsed = now - b.lastRefill; + b.tokens = Math.min(cap, b.tokens + elapsed * refillPerSec); + b.lastRefill = now; + if (b.tokens >= 1) { + b.tokens -= 1; + return true; + } + return false; +} + +export function allowRead(user: string): boolean { + return take(readBuckets, user, READ_CAP, READ_PER_MIN / 60); +} + +export function allowDestructive(user: string): boolean { + return take(destructiveBuckets, user, DESTRUCTIVE_CAP, DESTRUCTIVE_PER_HOUR / 3600); +} diff --git a/infrastructure/prom-admin/app/lib/validate.ts b/infrastructure/prom-admin/app/lib/validate.ts new file mode 100644 index 00000000..e3881760 --- /dev/null +++ b/infrastructure/prom-admin/app/lib/validate.ts @@ -0,0 +1,145 @@ +// Input validation + selector sanitization. +// +// The original delete-ui interpolated `labels` raw into PromQL — selector +// injection vector (`labels="foo=bar} or __name__=~\".+\""` would wipe +// everything). Here we PARSE the labels as `key="value"` pairs into a typed +// shape and re-serialize from the parsed form, so anything that doesn't fit +// the schema is rejected. + +// Hard caps. A single delete that wipes >7 days or covers >1000 series is +// almost certainly a mistake. The harness emits ~17k series/day, so 1000 is +// roughly 1.5h of one bench's full output. +export const MAX_RANGE_HOURS = 168; // 7 days +export const MAX_AGE_DAYS = 90; // can't delete data older than this +export const MAX_SERIES_PER_DELETE = 5000; + +// Metric allowlist. Only metrics in this prefix family are wipe-eligible. +// Add more names here, NOT in the user-facing form, when needed. +// Keeping this server-side prevents the UI from passing arbitrary metric +// names — even with a compromised browser bundle. +export const ALLOWED_METRIC_PREFIXES = [ + "head_lag_", + "l1_finality_", + "l2_block_time_", + "solana_quote_", + "solana_landing_", + "bridge_quote_", + "bridge_cost_", + "bridge_fee_", + "relay_revenue", + "relay_take_", + "relay_volume_", + "relay_swap_", + "metadata_coverage_", + "networks_supported_total", + "rpc_latency_", + "rpc_call_total", + "rpc_archive_", + "rpc_health", + "gas_oracle_", + "peg_", + "perp_fees_", + "ocb_buyback_", + "ocb_oracle_", + "ocb_chain_", + "hl_frontend_", + "wallet_labels_", +]; + +export function isAllowedMetric(name: string): boolean { + if (typeof name !== "string" || name.length === 0 || name.length > 100) return false; + if (!/^[a-z][a-z0-9_]*$/.test(name)) return false; + return ALLOWED_METRIC_PREFIXES.some( + (p) => name === p || name.startsWith(p) || name === p.replace(/_$/, ""), + ); +} + +export type LabelPair = { key: string; value: string }; + +// parseLabels accepts the freeform input from the UI in PromQL-style +// `key="value", key2="value2"` and turns it into a strict array of typed +// pairs. Anything that doesn't match is rejected — no parsing-tolerance. +// Keys: ^[a-zA-Z_][a-zA-Z0-9_]*$ +// Values: any printable, no `"` or `\`, max 200 chars. +export function parseLabels(input: string): LabelPair[] { + if (!input || input.trim() === "") return []; + const trimmed = input.trim(); + if (trimmed.length > 1000) { + throw new Error("labels string too long"); + } + const pairs: LabelPair[] = []; + const re = /([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*"([^"\\]{0,200})"\s*,?\s*/g; + let lastEnd = 0; + let m: RegExpExecArray | null; + while ((m = re.exec(trimmed)) !== null) { + if (m.index !== lastEnd) { + throw new Error(`labels: unexpected content at position ${lastEnd}`); + } + pairs.push({ key: m[1], value: m[2] }); + lastEnd = re.lastIndex; + } + if (lastEnd !== trimmed.length) { + throw new Error(`labels: unparsed remainder at position ${lastEnd}`); + } + if (pairs.length === 0) { + throw new Error("labels: no pairs parsed (expected key=\"value\")"); + } + if (pairs.length > 10) { + throw new Error("labels: too many pairs (max 10)"); + } + // Reject duplicate keys. + const seen = new Set(); + for (const p of pairs) { + if (seen.has(p.key)) { + throw new Error(`labels: duplicate key "${p.key}"`); + } + seen.add(p.key); + } + return pairs; +} + +// Serialize back to PromQL safe form. Each value re-escaped (we already know +// it has no `"` or `\` after parse). Order is preserved. +export function serializeLabels(pairs: LabelPair[]): string { + return pairs.map((p) => `${p.key}="${p.value}"`).join(","); +} + +export type ValidatedRange = { startTs: number; endTs: number }; + +export function validateRange(startTimeIso: string, endTimeIso: string): ValidatedRange { + const startTs = Math.floor(new Date(startTimeIso).getTime() / 1000); + const endTs = Math.floor(new Date(endTimeIso).getTime() / 1000); + if (!Number.isFinite(startTs) || !Number.isFinite(endTs)) { + throw new Error("range: invalid datetime"); + } + if (endTs <= startTs) { + throw new Error("range: end must be after start"); + } + const hours = (endTs - startTs) / 3600; + if (hours > MAX_RANGE_HOURS) { + throw new Error(`range: window too wide (${hours.toFixed(1)}h, max ${MAX_RANGE_HOURS}h)`); + } + const now = Math.floor(Date.now() / 1000); + const ageDays = (now - startTs) / 86400; + if (ageDays > MAX_AGE_DAYS) { + throw new Error(`range: start too far in the past (${ageDays.toFixed(0)}d, max ${MAX_AGE_DAYS}d)`); + } + if (endTs > now + 60) { + throw new Error("range: end in the future"); + } + return { startTs, endTs }; +} + +// buildMatcher builds the PromQL selector for delete_series / query. +// metric must be allowed (caller's job). labels are parsed pairs. +export function buildMatcher(metric: string, pairs: LabelPair[]): string { + if (pairs.length === 0) return metric; + return `${metric}{${serializeLabels(pairs)}}`; +} + +// confirmTokenFor returns the typed-slug confirmation string the UI must +// re-type to enable a destructive action. Format pins the metric + the start +// timestamp so a careless copy-paste from a different prepared action fails. +export function confirmTokenFor(metric: string, startTs: number): string { + return `delete-${metric}-${startTs}`; +} diff --git a/infrastructure/prom-admin/app/page.tsx b/infrastructure/prom-admin/app/page.tsx new file mode 100644 index 00000000..1c43a5eb --- /dev/null +++ b/infrastructure/prom-admin/app/page.tsx @@ -0,0 +1,693 @@ +'use client'; + +import { format } from 'date-fns'; +import { useCallback, useEffect, useId, useState } from 'react'; +import { + Brush, + CartesianGrid, + Line, + LineChart, + ReferenceArea, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; + +export default function Home() { + const startTimeId = useId(); + const endTimeId = useId(); + + const [chains, setChains] = useState(['All']); + const [regions, setRegions] = useState(['All']); + const [selectedChain, setSelectedChain] = useState('All'); + const [selectedRegion, setSelectedRegion] = useState('All'); + const [startTime, setStartTime] = useState(''); + const [endTime, setEndTime] = useState(''); + const [loading, setLoading] = useState(false); + const [chartData, setChartData] = useState([]); + const [chartLoading, setChartLoading] = useState(false); + const [hoursToShow, setHoursToShow] = useState(24); + + // Smart clean state + const [smartThreshold, setSmartThreshold] = useState(5); + const [smartHours, setSmartHours] = useState(24); + const [smartCleanLoading, setSmartCleanLoading] = useState(false); + const [smartCleanResults, setSmartCleanResults] = useState(null); + + // Zoom state + const [zoomDomain, setZoomDomain] = useState<[number, number] | null>(null); + const [zoomLevel, setZoomLevel] = useState(1); + + // Selection state for click-and-drag + const [refAreaLeft, setRefAreaLeft] = useState(null); + const [refAreaRight, setRefAreaRight] = useState(null); + const [isSelecting, setIsSelecting] = useState(false); + + const buildLabels = useCallback(() => { + const parts = ['aggregator="mobula"']; + if (selectedChain !== 'All') parts.push(`chain="${selectedChain}"`); + if (selectedRegion !== 'All') parts.push(`region="${selectedRegion}"`); + return parts.join(','); + }, [selectedChain, selectedRegion]); + + const loadChart = useCallback(async () => { + setChartLoading(true); + try { + const labels = buildLabels(); + const res = await fetch('/api/chart', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ metric: 'head_lag_seconds', labels, hours: hoursToShow }), + }); + const data = await res.json(); + setChartData(data.data || []); + } catch (error) { + console.error('Chart error:', error); + } + setChartLoading(false); + }, [buildLabels, hoursToShow]); + + useEffect(() => { + // Load available labels + fetch('/api/labels') + .then((res) => res.json()) + .then((data) => { + if (data.chains) setChains(data.chains); + if (data.regions) setRegions(data.regions); + }); + }, []); + + useEffect(() => { + loadChart(); + }, [loadChart]); + + const handleBrushChange = (domain: any) => { + if (domain?.startIndex !== undefined && domain?.endIndex !== undefined) { + const start = chartData[domain.startIndex]?.timestamp; + const end = chartData[domain.endIndex]?.timestamp; + + if (start && end) { + setStartTime(format(new Date(start), "yyyy-MM-dd'T'HH:mm:ss")); + setEndTime(format(new Date(end), "yyyy-MM-dd'T'HH:mm:ss")); + } + } + }; + + const handleMouseDown = (e: any) => { + if (e?.activeLabel) { + setRefAreaLeft(e.activeLabel); + setIsSelecting(true); + } + }; + + const handleMouseMove = (e: any) => { + if (isSelecting && e?.activeLabel) { + setRefAreaRight(e.activeLabel); + } + }; + + const handleMouseUp = (e: any) => { + if (refAreaLeft && refAreaRight) { + const start = Math.min(refAreaLeft, refAreaRight); + const end = Math.max(refAreaLeft, refAreaRight); + + setStartTime(format(new Date(start), "yyyy-MM-dd'T'HH:mm:ss")); + setEndTime(format(new Date(end), "yyyy-MM-dd'T'HH:mm:ss")); + } + + setRefAreaLeft(null); + setRefAreaRight(null); + setIsSelecting(false); + }; + + const handleZoomToSelection = () => { + if (!startTime || !endTime) return; + + const start = new Date(startTime).getTime(); + const end = new Date(endTime).getTime(); + + setZoomDomain([start, end]); + setZoomLevel(prev => prev + 1); + }; + + const handleResetZoom = () => { + setZoomDomain(null); + setZoomLevel(1); + }; + + const handleQuickClean = async () => { + if (!startTime || !endTime) return; + + const startDate = new Date(startTime); + const endDate = new Date(endTime); + const startStr = startDate.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); + const endStr = endDate.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); + const startUTC = startDate.toISOString().slice(0, 19).replace('T', ' '); + const endUTC = endDate.toISOString().slice(0, 19).replace('T', ' '); + + const labels = buildLabels(); + const confirmMsg = `Delete head_lag_seconds?\n\nFilters: ${labels}\nLocal: ${startStr} → ${endStr}\nUTC: ${startUTC} → ${endUTC}\n\n⚠️ Exact range (no margin)`; + + if (!confirm(confirmMsg)) return; + + setLoading(true); + try { + const labels = buildLabels(); + const res = await fetch('/api/delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + startTime, + endTime, + metric: 'head_lag_seconds', + labels, + }), + }); + const _data = await res.json(); + alert(`Timeline deleted ✓`); + setStartTime(''); + setEndTime(''); + + // Wait 2 seconds for Prometheus to finish cleaning tombstones + await new Promise((resolve) => setTimeout(resolve, 2000)); + loadChart(); + } catch (error) { + console.error('Delete error:', error); + alert('❌ Error during deletion'); + } + setLoading(false); + }; + + const handleSmartClean = async (dryRun: boolean) => { + setSmartCleanLoading(true); + setSmartCleanResults(null); + + try { + const res = await fetch('/api/smart-clean', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + threshold: smartThreshold, + hours: smartHours, + dryRun, + }), + }); + const data = await res.json(); + if (!res.ok || data.error) { + alert(`❌ Smart clean failed: ${data.error ?? `HTTP ${res.status}`}`); + setSmartCleanLoading(false); + return; + } + setSmartCleanResults(data); + + if (!dryRun && data.deleted) { + await new Promise((resolve) => setTimeout(resolve, 2000)); + loadChart(); + } + } catch (error) { + console.error('Smart clean error:', error); + alert('❌ Error during smart clean'); + } + setSmartCleanLoading(false); + }; + + return ( +
+
+

🗑️ Head Lag Cleaner - Mobula

+ + {/* Chain Filters */} +
+ +
+ {chains.map((chain) => ( + + ))} +
+
+ + {/* Region Filters */} +
+ +
+ {regions.map((region) => ( + + ))} +
+
+ + {/* Time Range Selector */} +
+ +
+ {[6, 12, 24, 48, 72].map((hours) => ( + + ))} +
+
+ + {/* Chart */} +
+
+
+
+

+ {selectedChain !== 'All' ? selectedChain : 'All Chains'} + {selectedRegion !== 'All' ? ` - ${selectedRegion}` : ''} +

+

+ Drag to select • Click "Zoom Here" to zoom on selection • Local timezone (UTC{new Date().getTimezoneOffset() > 0 ? '-' : '+'}{Math.abs(Math.floor(new Date().getTimezoneOffset() / 60))}) +

+
+
+
+ {zoomDomain && ( + + )} + {startTime && endTime && ( + <> +
+ Selected: {format(new Date(startTime), 'HH:mm:ss')} → {format(new Date(endTime), 'HH:mm:ss')} +
+ + + + )} + +
+
+ + {chartLoading ? ( +
Loading chart...
+ ) : chartData.length > 0 ? ( + + + + format(new Date(ts), 'HH:mm:ss')} + stroke="#888" + allowDataOverflow={true} + /> + + format(new Date(ts), 'yyyy-MM-dd HH:mm:ss')} + formatter={(value: any) => [value !== null && value !== undefined ? value.toFixed(3) : 'N/A', 'lag (s)']} + allowEscapeViewBox={{ x: true, y: true }} + isAnimationActive={false} + /> + + {refAreaLeft && refAreaRight && ( + + )} + format(new Date(ts), 'HH:mm')} + /> + + + ) : ( +
No data available
+ )} +
+ + {/* Time Selection */} +
+
+ + setStartTime(e.target.value)} + style={{ + width: '100%', + padding: '10px', + background: '#0f1419', + color: '#fff', + border: '1px solid #333', + borderRadius: '6px', + fontSize: '14px', + }} + /> + {startTime && ( +

+ UTC: {new Date(startTime).toISOString().replace('T', ' ').slice(0, 19)} +

+ )} +
+ +
+ + setEndTime(e.target.value)} + style={{ + width: '100%', + padding: '10px', + background: '#0f1419', + color: '#fff', + border: '1px solid #333', + borderRadius: '6px', + fontSize: '14px', + }} + /> + {endTime && ( +

+ UTC: {new Date(endTime).toISOString().replace('T', ' ').slice(0, 19)} +

+ )} +
+
+
+ + {/* Smart Clean Section */} +
+

⚡ Smart Clean - Auto Detect & Delete Spikes

+

+ Automatically detect and delete all spikes above a threshold across all regions and chains +

+ +
+
+ + setSmartThreshold(Number.parseFloat(e.target.value))} + style={{ + width: '100%', + padding: '10px', + background: '#0f1419', + color: '#fff', + border: '1px solid #333', + borderRadius: '6px', + fontSize: '14px', + }} + /> +
+ +
+ + setSmartHours(Number.parseInt(e.target.value))} + style={{ + width: '100%', + padding: '10px', + background: '#0f1419', + color: '#fff', + border: '1px solid #333', + borderRadius: '6px', + fontSize: '14px', + }} + /> +
+
+ +
+ + +
+ + {smartCleanResults && ( +
+
+
+ {smartCleanResults.dryRun ? '🔍 Preview Results:' : '✅ Clean Results:'} +
+
+ Found {smartCleanResults.totalSpikes} spike points → grouped into {smartCleanResults.totalGroups} ranges +
+ {!smartCleanResults.dryRun && ( +
+ Deleted: {smartCleanResults.deleted} / Failed: {smartCleanResults.failed} +
+ )} +
+ + {smartCleanResults.groups && smartCleanResults.groups.length > 0 && ( +
+ + + + + + + + + + + + {smartCleanResults.groups.map((group: any, idx: number) => ( + + + + + + + + ))} + +
RegionChainStartEndDuration
{group.region}{group.chain} + {format(new Date(group.startTime), 'MM/dd HH:mm:ss')} + + {format(new Date(group.endTime), 'MM/dd HH:mm:ss')} + {group.duration}s
+
+ )} +
+ )} +
+
+ ); +} diff --git a/infrastructure/prom-admin/middleware.ts b/infrastructure/prom-admin/middleware.ts new file mode 100644 index 00000000..adca8853 --- /dev/null +++ b/infrastructure/prom-admin/middleware.ts @@ -0,0 +1,81 @@ +// Edge middleware — gates EVERY admin route behind HTTP Basic Auth. +// +// This is Layer 2 of the auth stack: +// Layer 1: Cloudflare Access (user identity, email allowlist) +// Layer 2: this — HTTP Basic Auth (shared password, env-injected) +// Layer 3: Caddy gateway in front of Prom (bearer token, server-side only) +// +// The 3 layers protect against different failure modes — see README. +// +// Defense-in-depth note: the Basic Auth secret here is NOT the same as the +// Caddy bearer. A leak of one does not grant access to the other. +import { NextRequest, NextResponse } from "next/server"; + +const REALM = 'OCB Prom Admin'; + +export function middleware(req: NextRequest) { + // /health is intentionally unauthenticated so Railway's healthcheck works. + if (req.nextUrl.pathname === "/health") { + return NextResponse.next(); + } + + const expectedUser = process.env.ADMIN_USER ?? ""; + const expectedPass = process.env.ADMIN_PASS ?? ""; + + if (!expectedUser || !expectedPass) { + // Fail-closed: misconfiguration MUST block, not allow. + return new NextResponse( + "server misconfigured — ADMIN_USER / ADMIN_PASS env vars not set", + { status: 503 }, + ); + } + + const header = req.headers.get("authorization") ?? ""; + if (!header.startsWith("Basic ")) { + return new NextResponse("auth required", { + status: 401, + headers: { "WWW-Authenticate": `Basic realm="${REALM}", charset="UTF-8"` }, + }); + } + + let user = ""; + let pass = ""; + try { + const decoded = atob(header.slice(6)); + const idx = decoded.indexOf(":"); + if (idx < 0) throw new Error("malformed"); + user = decoded.slice(0, idx); + pass = decoded.slice(idx + 1); + } catch { + return new NextResponse("bad auth header", { + status: 401, + headers: { "WWW-Authenticate": `Basic realm="${REALM}", charset="UTF-8"` }, + }); + } + + // Constant-time-ish comparison via length-then-byte-loop. Edge runtime has + // no `crypto.timingSafeEqual`, so we DIY. + if (!safeEq(user, expectedUser) || !safeEq(pass, expectedPass)) { + return new NextResponse("invalid credentials", { + status: 401, + headers: { "WWW-Authenticate": `Basic realm="${REALM}", charset="UTF-8"` }, + }); + } + + // Pass the authenticated user down to route handlers via a custom header so + // audit log can attribute actions. + const res = NextResponse.next(); + res.headers.set("x-admin-user", user); + return res; +} + +function safeEq(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); + return diff === 0; +} + +export const config = { + matcher: ["/((?!_next/static|_next/image|favicon.ico|health).*)"], +}; diff --git a/infrastructure/prom-admin/next.config.ts b/infrastructure/prom-admin/next.config.ts new file mode 100644 index 00000000..75b92925 --- /dev/null +++ b/infrastructure/prom-admin/next.config.ts @@ -0,0 +1,12 @@ +import type { NextConfig } from 'next'; + +const nextConfig: NextConfig = { + eslint: { + ignoreDuringBuilds: true, + }, + typescript: { + ignoreBuildErrors: true, + }, +}; + +export default nextConfig; \ No newline at end of file diff --git a/infrastructure/prom-admin/package-lock.json b/infrastructure/prom-admin/package-lock.json new file mode 100644 index 00000000..afa8b11a --- /dev/null +++ b/infrastructure/prom-admin/package-lock.json @@ -0,0 +1,1364 @@ +{ + "name": "openchainbench-prom-admin", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "openchainbench-prom-admin", + "version": "1.0.0", + "dependencies": { + "date-fns": "4.1.0", + "next": "15.5.18", + "react": "19.2.6", + "react-dom": "19.2.6", + "recharts": "2.15.4" + }, + "devDependencies": { + "@types/node": "22.19.19", + "@types/react": "19.2.15", + "@types/react-dom": "19.2.3", + "typescript": "5.9.3" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@next/env": { + "version": "15.5.18", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.18.tgz", + "integrity": "sha512-hAV85Ckd9QR6RvH04MEKwsfLTksvFpO47j9xwtoIuvuPnlwecpSi+uZTtm8HirVbtlI2Fnz//xpcSTjFdyJk+g==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.5.18", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.18.tgz", + "integrity": "sha512-w0WvQf1n+txiwns/9pwIQteCJpZTbxzO2SE0FLcwuD4v0WEh1JPOjdyxWL21XwJsdpx8cFRjyzxzCS/siP7HcQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.5.18", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.18.tgz", + "integrity": "sha512-znn71QmDuxm+BOaglihMZfvyySMnNljkVIY5Z2TCssBmm+WqL6c19VhtH5ktFkHa8EZ2bnTUpcNcmNSQsg67og==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.5.18", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.18.tgz", + "integrity": "sha512-yPPe5MNL+igZUa+OsqQJisqSfh6oarIuA1Q0BDxljGJhRQyZeP+WRHh7rs/jZUGMh5aY0YdIjXZG0VohkKkUdw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.5.18", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.18.tgz", + "integrity": "sha512-glaCczEWIrHsokFZ3pP08U4BpKxwIdnT+txdOM32OBgpL9Yw4aqx8NejmgtZQZOdstQ5f0L3CasIZudzCuD+nw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.5.18", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.18.tgz", + "integrity": "sha512-oUfg2EgJmU3R0OCOWiokGFUTvZiPfXtriXiuF3YNxRoROCdgvTedHIzYoeKH34gsZxS/V7mHbfq2hpAHwhH1/A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.5.18", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.18.tgz", + "integrity": "sha512-JLxSP3KTd9iu/bvUMQxH7RJo9xKSHf55/6RPE4a6FTSZygGn7uvZbCej0AHXydwkggQGSD9UddSjwv6Xz5ESfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.5.18", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.18.tgz", + "integrity": "sha512-ir1v7enP52K2HNz3tQQvwF+x7VNxBk1ciiZ18WBPvxf4C59IqdfmHPJYK3vH7rSxpuCVw/8C712wTXNAtEp+NA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.5.18", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.18.tgz", + "integrity": "sha512-LIu5me6QTANCd25E7I5uIEfvgQ06RK7tvHAbYo3zCb3VpxQEPvMcSpd87NwUABDT6MbGPdEGR5VRiK4PPTJhQg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", + "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "15.5.18", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.18.tgz", + "integrity": "sha512-eKL8zUJkX9Y5lE+RX/2YJoItVdGlIscyVyboeD9wSpp0PaGqjoA4tTpT2qPqz9ax+5IzGESyLSeZ/RCwbSZ2uQ==", + "license": "MIT", + "dependencies": { + "@next/env": "15.5.18", + "@swc/helpers": "0.5.15", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.5.18", + "@next/swc-darwin-x64": "15.5.18", + "@next/swc-linux-arm64-gnu": "15.5.18", + "@next/swc-linux-arm64-musl": "15.5.18", + "@next/swc-linux-x64-gnu": "15.5.18", + "@next/swc-linux-x64-musl": "15.5.18", + "@next/swc-win32-arm64-msvc": "15.5.18", + "@next/swc-win32-x64-msvc": "15.5.18", + "sharp": "^0.34.3" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.6" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "deprecated": "1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + } + } +} diff --git a/infrastructure/prom-admin/package.json b/infrastructure/prom-admin/package.json new file mode 100644 index 00000000..527fa702 --- /dev/null +++ b/infrastructure/prom-admin/package.json @@ -0,0 +1,23 @@ +{ + "name": "openchainbench-prom-admin", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start -p ${PORT:-3000}" + }, + "dependencies": { + "next": "15.5.18", + "react": "19.2.6", + "react-dom": "19.2.6", + "recharts": "2.15.4", + "date-fns": "4.1.0" + }, + "devDependencies": { + "@types/node": "22.19.19", + "@types/react": "19.2.15", + "@types/react-dom": "19.2.3", + "typescript": "5.9.3" + } +} diff --git a/infrastructure/prom-admin/railway.toml b/infrastructure/prom-admin/railway.toml new file mode 100644 index 00000000..5232bea6 --- /dev/null +++ b/infrastructure/prom-admin/railway.toml @@ -0,0 +1,11 @@ +[build] +builder = "DOCKERFILE" +dockerfilePath = "Dockerfile" + +[deploy] +# CMD defined in Dockerfile (./node_modules/.bin/next start). Don't override +# here — Railway docs say startCommand on DOCKERFILE builder skips the +# Dockerfile CMD entirely, so if we set it again we lose the absolute path +# trick that made it actually find next. +restartPolicyType = "ON_FAILURE" +restartPolicyMaxRetries = 3 diff --git a/infrastructure/prom-admin/tsconfig.json b/infrastructure/prom-admin/tsconfig.json new file mode 100644 index 00000000..240013a7 --- /dev/null +++ b/infrastructure/prom-admin/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} \ No newline at end of file From e81cc97d5990835ed3d6681454f2edb416397196 Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Fri, 3 Jul 2026 18:22:13 +0200 Subject: [PATCH 076/144] harnesses: last missing .env.example templates (#889) Co-authored-by: Florent Tapponnier --- harnesses/evm-swap-quoting/.env.example | 17 ++++++++------ harnesses/pm-freshness-bench/.env.example | 18 +++++++++++++++ harnesses/pm-rate-limits/.env.example | 28 +++++++++++++++++++++++ 3 files changed, 56 insertions(+), 7 deletions(-) create mode 100644 harnesses/pm-freshness-bench/.env.example create mode 100644 harnesses/pm-rate-limits/.env.example diff --git a/harnesses/evm-swap-quoting/.env.example b/harnesses/evm-swap-quoting/.env.example index f3842abb..62fe839e 100644 --- a/harnesses/evm-swap-quoting/.env.example +++ b/harnesses/evm-swap-quoting/.env.example @@ -5,15 +5,18 @@ MOBULA_API_KEY= # Allowed: us-east, eu-west, sgp MONITOR_REGION=eu-west -# Optional: free-tier keys for the 3 providers that need them. Without these -# the harness simply skips the corresponding provider; metrics for that -# provider stay zero. Sign-up: -# https://portal.1inch.dev (100k req/month, 1 RPS) -# https://dashboard.0x.org (200k req/month) -# https://app.odos.xyz/api (avoids the anonymous 3-call rate limit) +# Optional: free-tier keys for the providers that need them. Without these +# the harness simply skips the corresponding provider (or runs anonymous +# with degraded throughput, in Enso's case). Sign-up: +# https://portal.1inch.dev (100k req/month, 1 RPS) +# https://app.odos.xyz/api (avoids the anonymous 3-call rate limit) +# https://shortcuts.enso.finance (widens the anonymous 1rps bucket; +# without a key Enso is ~90 % throttled +# because the bucket is shared across +# all anonymous callers worldwide) ONEINCH_API_KEY= -ZEROX_API_KEY= ODOS_API_KEY= +ENSO_API_KEY= # Optional token-gate on /logs?tail=N. Same shared token as the other OCB # harnesses if you want one curl to fetch logs across services. diff --git a/harnesses/pm-freshness-bench/.env.example b/harnesses/pm-freshness-bench/.env.example new file mode 100644 index 00000000..53c28cc7 --- /dev/null +++ b/harnesses/pm-freshness-bench/.env.example @@ -0,0 +1,18 @@ +# Codex (defined.fi) JWT scraping. Cookie rotated every ~7 days, see +# aggregator-latency-benchmark/CODEX_SESSION_GUIDE.md for refresh. +DEFINED_SESSION_COOKIE= + +# Webshare rotating proxy — required for Codex JWT mint (Vercel bans direct IPs). +HTTP_PROXY= +HTTPS_PROXY= + +# Mobula PM API key. Needs Growth or Enterprise plan for PM streams. +MOBULA_API_KEY= + +# Optional: token-gate /logs and /debug endpoints. +LOGS_TOKEN= + +# Optional: bench cadence. Defaults are sane. +# REFRESH_MARKETS_INTERVAL_SEC=300 # how often to re-poll gamma-api for the basket +# BASKET_SIZE=20 # how many top-vol markets to subscribe per provider +# KALSHI_POLL_INTERVAL_SEC=5 # /v1/social/trades cadence (CloudFront max-age=10) diff --git a/harnesses/pm-rate-limits/.env.example b/harnesses/pm-rate-limits/.env.example new file mode 100644 index 00000000..8a5bacbc --- /dev/null +++ b/harnesses/pm-rate-limits/.env.example @@ -0,0 +1,28 @@ +# Region label stamped on every metric. Set explicitly when running one +# Railway service per region (us-east | eu-west | sgp). Falls back to +# RAILWAY_REPLICA_REGION, then "eu-west". +REGION=eu-west + +# Token gating GET :2112/logs (X-Logs-Token header). Unset = /logs disabled. +LOGS_TOKEN= + +# Daily ramp crash test. Default hour by region: us-east 02, eu-west 04, +# sgp 06 UTC, so two regions never ramp a venue at the same time. +# RAMP_HOUR_UTC=4 +# RAMP_DISABLED=1 + +# Aggregator providers. Each key is independent: the matching provider +# goroutine only runs when its key is non-empty (logged at startup as +# "[providers] mobula=… predexon=… codex=…"). Direct venue probes need +# no key and always run. +# +# MOBULA_API_KEY — Authorization header on https://api.mobula.io +# PREDEXON_API_KEY — x-api-key header on https://api.predexon.com +# (global 1 rps cap across the org, gated in-process) +# DEFINED_SESSION_COOKIE — 7-day session cookie used to mint Codex JWTs +# via www.defined.fi. Routes through HTTP_PROXY / +# HTTPS_PROXY (Webshare); direct Railway IPs hit +# Vercel's bot-ban loop after a few mints. +MOBULA_API_KEY= +PREDEXON_API_KEY= +DEFINED_SESSION_COOKIE= From 95af2c0f01fec7876bae6ed6e59ca15fc42b96f0 Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Fri, 3 Jul 2026 18:44:11 +0200 Subject: [PATCH 077/144] rpc harness: record latency only on fresh valid responses (#891) Co-authored-by: Florent Tapponnier --- harnesses/rpc-capabilities/cmd/script/probe.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/harnesses/rpc-capabilities/cmd/script/probe.go b/harnesses/rpc-capabilities/cmd/script/probe.go index b6324445..b170a604 100644 --- a/harnesses/rpc-capabilities/cmd/script/probe.go +++ b/harnesses/rpc-capabilities/cmd/script/probe.go @@ -136,8 +136,6 @@ func probeOne(ctx context.Context, c Chain, p Provider) { probeCtx, cancel := context.WithTimeout(ctx, probeTimeout) defer cancel() block, result, latency, err := callBlockNumber(probeCtx, p.URL) - rpcLatency.WithLabelValues(p.Slug, c.Slug, currentRegion).Set(latency) - rpcLatencyHist.WithLabelValues(p.Slug, c.Slug, currentRegion).Observe(latency) if result == "ok" { tips.update(c.Slug, block) @@ -148,9 +146,19 @@ func probeOne(ctx context.Context, c Chain, p Provider) { } rpcCallTotal.WithLabelValues(p.Slug, c.Slug, currentRegion, result).Inc() if result == "ok" { + // Latency is recorded ONLY for fresh, valid responses. Error + // responses are often FASTER than real work (Cloudflare's dead + // eth endpoint 403s in ~20 ms and was topping the Ethereum + // leaderboard for a week), and a gauge set on failure freezes + // at that value forever. Deleting the series on failure lets + // Prom staleness kick in so dead providers age out of the + // p50 rankings instead of ranking on their last error's RTT. + rpcLatency.WithLabelValues(p.Slug, c.Slug, currentRegion).Set(latency) + rpcLatencyHist.WithLabelValues(p.Slug, c.Slug, currentRegion).Observe(latency) rpcHealth.WithLabelValues(p.Slug, c.Slug, currentRegion).Set(1) fmt.Printf("[%s/%s] block=%d latency=%.0fms\n", c.Slug, p.Slug, block, latency) } else { + rpcLatency.DeleteLabelValues(p.Slug, c.Slug, currentRegion) rpcHealth.WithLabelValues(p.Slug, c.Slug, currentRegion).Set(0) fmt.Printf("[%s/%s] %s latency=%.0fms err=%v\n", c.Slug, p.Slug, result, latency, err) } From 30803de2ce89a658d6249b2e8012afacbc14e188 Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:33:21 +0200 Subject: [PATCH 078/144] keep unresponsive cohort providers visible as unranked ledger rows (#893) Dead endpoints (probed, all calls failing) no longer vanish when their latency series goes Prom-stale: the materializer flags them via the still-live call counters and the ledger pins them at the bottom with success rate, no rank, no latency. Bumps bench-unfiltered-v19 / all-benchmarks-v23 / bench-filters-v11 and MAT_SCHEMA_VERSION 2 (deploy worker before site). Co-authored-by: Florent Tapponnier --- src/components/ledger-table.tsx | 126 ++++++++++++++++++++-- src/components/rpc-chains-leaderboard.tsx | 16 ++- src/lib/materialize/load.test.ts | 58 ++++++++++ src/lib/materialize/load.ts | 103 ++++++++++++++++-- src/lib/materialize/schema.ts | 8 +- src/lib/rpc-hub-stats.ts | 12 ++- src/lib/spec.ts | 12 ++- src/types/benchmark.ts | 11 ++ 8 files changed, 323 insertions(+), 23 deletions(-) create mode 100644 src/lib/materialize/load.test.ts diff --git a/src/components/ledger-table.tsx b/src/components/ledger-table.tsx index b86dae51..eb627375 100644 --- a/src/components/ledger-table.tsx +++ b/src/components/ledger-table.tsx @@ -239,8 +239,20 @@ export function LedgerTable({ } }; + // Unresponsive cohort members: probed every cycle, (nearly) every call + // fails, so no latency percentile exists in the current view. Rendered + // as unranked, muted rows pinned BELOW the ranked field — visible with + // their success rate instead of silently vanishing when Prom staleness + // drops the latency series — and excluded from ranks, the field mean, + // and the data-bar scale so they can't distort any claim above. + const unresponsiveRows = [...results] + .filter((r) => r.unresponsive) + .sort((a, b) => b.successRate - a.successRate); + const sortedAll = [...results] .filter((r) => { + // Unresponsive rows render in their own unranked block below. + if (r.unresponsive) return false; // Sample-health gate. Rows tagged "insufficient" by the load path // (sampleSize < 0.1 × expectedN) drop out of the ranking entirely // so the leaderboard cannot assert a position from a wildly @@ -533,6 +545,33 @@ export function LedgerTable({ embedKind={embedKind} /> ))} + {unresponsiveRows.map((r) => ( + ({ + v: null, + unit: colUnit(c), + }))} + series={[]} + sparkMin={sparkMin} + sparkMax={sparkMax} + color="var(--color-ink-faint)" + benchmark={benchmark} + embedChain={embedChain} + embedRegion={embedRegion} + embedKind={embedKind} + /> + ))} @@ -582,13 +621,19 @@ function Row({ embedRegion: string | null; embedKind: string | null; }) { - const isOffline = r.availability === "unavailable"; + // Unresponsive: in the cohort, probed, everything fails — unranked row + // pinned below the field with its success rate and dashed-out latency. + // Distinct from isOffline ("no data at all this cycle"): the counters + // still prove the endpoint is being measured, so we show that story. + const isUnresponsive = !!r.unresponsive; + const isOffline = !isUnresponsive && r.availability === "unavailable"; + const isMuted = isOffline || isUnresponsive; const deltaPct = fieldValue > 0 ? ((value - fieldValue) / fieldValue) * 100 : 0; const deltaSign = deltaPct > 0 ? "+" : deltaPct < 0 ? "−" : "±"; const barPct = Math.max(2, (value / maxValue) * 100); return ( - + {/* Color accent. left edge of row */} - {String(i + 1).padStart(2, "0")} + {/* Unresponsive rows never take a rank number: best/worst SEO + copy and badge claims are computed from healthy rows only, + and a numbered dead row would contradict them. */} + {isUnresponsive ? "—" : String(i + 1).padStart(2, "0")} {/* itemScope/itemType marks each row as a named entity so Google's knowledge graph can link the leaderboard back to that provider. @@ -629,7 +677,7 @@ function Row({ // and the link reappears automatically. {r.name} @@ -638,13 +686,13 @@ function Row({ {r.name} )} - {r.tag && !isOffline && ( + {r.tag && !isMuted && ( {r.tag} @@ -657,7 +705,15 @@ function Row({ )} - {!isOffline && r.dataConfidence === "low" && ( + {isUnresponsive && ( + + + + Unresponsive + + + )} + {!isMuted && r.dataConfidence === "low" && ( )} - {r.type && !isOffline && ( + {r.type && !isMuted && ( )} - {!isOffline && !isRegion(r.slug) && ( + {!isMuted && !isRegion(r.slug) && ( Awaiting next successful scrape + ) : isUnresponsive ? ( + // Latency cells dash out (no successful probe = no latency to + // report) but the Success column stays populated: the reliability + // number IS the finding for a dead endpoint. + <> + + — + + {customCells ? ( + customCells.slice(1).map((c, idx) => ( + + — + + )) + ) : panelActive || singleValueColumn ? null : ( + <> + + — + + + — + + + — + + + )} + + — + + + {r.successRate.toFixed(2)}% + + — + {hasSlots && ( + + - + + )} + {hasSecondary && ( + + - + + )} + ) : ( <> {/* Headline column with inline data bar */} diff --git a/src/components/rpc-chains-leaderboard.tsx b/src/components/rpc-chains-leaderboard.tsx index 74ec01cf..6052b681 100644 --- a/src/components/rpc-chains-leaderboard.tsx +++ b/src/components/rpc-chains-leaderboard.tsx @@ -211,7 +211,21 @@ export function RpcChainsLeaderboard({ rows }: { rows: RpcHubChain[] }) { ); })} - {r.providerCount} + 1 ? "s" : ""} currently unresponsive on ${r.name} (probed, all calls failing). Not counted in best/fastest.` + : undefined + } + > + {r.providerCount} + {r.unresponsiveCount ? ( + + +{r.unresponsiveCount} down + + ) : null} + {r.series && r.series.length > 1 ? ( { + test("null when the provider was not provably probed (no counters)", () => { + // A provider that doesn't cover the current chain/region slice, or a + // bench without success/sample_size queries: must NOT fake-offline. + expect(unresponsiveResult(provider, { success: null, sampleSize: null })).toBeNull(); + expect(unresponsiveResult(provider, { success: null, sampleSize: 0 })).toBeNull(); + }); + + test("flags a probed provider whose ok-rate series is entirely absent", () => { + // Cloudflare on ethereum: rpc_call_total keeps counting, but zero ok + // samples in the window makes the success division come back empty. + const r = unresponsiveResult(provider, { success: null, sampleSize: 8641 }); + expect(r).not.toBeNull(); + expect(r!.unresponsive).toBe(true); + expect(r!.availability).toBe("unavailable"); + expect(r!.successRate).toBe(0); + expect(r!.sampleSize).toBe(8641); + expect(r!.ms).toEqual({ p50: 0, p90: 0, p99: 0, mean: 0 }); + }); + + test("flags a probed provider with a near-zero success ratio", () => { + // 1RPC with its IP quota exhausted: ~1.7% of calls succeed. + const r = unresponsiveResult(provider, { success: 0.0169, sampleSize: 8643 }); + expect(r).not.toBeNull(); + expect(r!.unresponsive).toBe(true); + expect(r!.successRate).toBeCloseTo(1.69, 2); + }); + + test("null when the success rate is healthy (transient percentile miss)", () => { + // Latency comes from the same probes as OK results — a healthy + // success rate next to a null p50 is a transient Prom read failure, + // not a dead endpoint. Must not badge. + expect(unresponsiveResult(provider, { success: 0.998, sampleSize: 8640 })).toBeNull(); + // Same input already expressed in percent (legacy formulas). + expect(unresponsiveResult(provider, { success: 99.8, sampleSize: 8640 })).toBeNull(); + }); + + test("carries provider identity fields through", () => { + const r = unresponsiveResult(provider, { success: 0, sampleSize: 100 }); + expect(r!.slug).toBe("cloudflare"); + expect(r!.name).toBe("Cloudflare"); + expect(r!.tag).toBe(provider.tag); + expect(r!.formula).toBe(provider.formula); + }); +}); diff --git a/src/lib/materialize/load.ts b/src/lib/materialize/load.ts index 370e4b97..ad5d52f5 100644 --- a/src/lib/materialize/load.ts +++ b/src/lib/materialize/load.ts @@ -139,8 +139,11 @@ export async function specToBenchmark( const live = await tryLoadLive(filteredSpec, isFiltered); if (live) { // Mark live entries explicitly so a missing `availability` reads as - // "unknown" everywhere else in the code. - for (const r of live.results) r.availability = "live"; + // "unknown" everywhere else in the code. Unresponsive rows keep + // their "unavailable" marker so no ranking surface counts them. + for (const r of live.results) { + if (!r.unresponsive) r.availability = "live"; + } // Augment with spec-declared providers that didn't return data this // cycle, but only on the *unfiltered* view. When the reader has @@ -191,6 +194,10 @@ export async function specToBenchmark( if (live.metricPanels && live.metricPanels.length > 0) { for (const r of live.results) { if (r.availability !== "unavailable") continue; + // Never promote unresponsive rows: their call counters ARE + // panel-shaped data on some benches, but a 0-2% success rate + // is exactly the condition the badge exists to surface. + if (r.unresponsive) continue; const slug = r.slug.toLowerCase(); const hasPanelData = live.metricPanels.some((panel) => { const v = panel.values?.[r.slug] ?? panel.values?.[slug]; @@ -236,7 +243,9 @@ export async function specToBenchmark( if (!chainLive) { return [chain, undefined, undefined, [] as string[]] as const; } - for (const r of chainLive.results) r.availability = "live"; + for (const r of chainLive.results) { + if (!r.unresponsive) r.availability = "live"; + } const liveForChain = liveProviderResults(chainLive.results); const slugs = liveForChain.map((r) => r.slug); if (liveForChain.length === 0) { @@ -280,6 +289,11 @@ export async function specToBenchmark( const expectedN = spec.expected_n; if (expectedN) { for (const r of live.results) { + // Unresponsive rows carry a full probe count (the calls run, + // they just fail) — classifying them "healthy" would read as a + // contradiction next to the badge, and aggregateConfidence + // already skips unavailable rows. Leave them unclassified. + if (r.unresponsive) continue; const h = classifyHealth(r.sampleSize, expectedN); if (h) { r.dataConfidence = h.confidence; @@ -534,6 +548,61 @@ function escapePromLabelValue(v: string): string { .replace(/[\r\n]/g, ""); } +/** Success-rate ceiling (in percent) under which a latency-less cohort + * member is classified "unresponsive" rather than transiently + * unreadable. Latency percentiles come from the same probes as OK + * results (the harness only records latency for successful calls), so + * any real success share implies the latency series exists — a null + * percentile next to a healthy success rate is a transient Prom read + * failure, not a dead endpoint, and must not be badged. */ +const UNRESPONSIVE_MAX_SUCCESS_PCT = 5; + +/** + * Build the unranked "unresponsive" entry for a spec-declared provider + * whose latency percentiles returned nothing this cycle. Returns null + * when the provider wasn't provably probed in the current view (no + * success sample AND no call-count sample), which keeps the behavior + * scoped to specs that declare reliability queries (the RPC family): + * benches without `success` / `sample_size` queries never produce these + * rows, and chain/region variants only flag providers whose counters + * actually exist on that slice (a provider that simply doesn't cover + * the filtered chain stays absent, not fake-offline). + * + * Exported for unit tests. + */ +export function unresponsiveResult( + p: Spec["providers"][number], + probe: { success: number | null; sampleSize: number | null }, +): ProviderResult | null { + const probed = + (probe.sampleSize != null && probe.sampleSize > 0) || probe.success != null; + if (!probed) return null; + // Same ratio-vs-percent normalization as the live path. A missing + // success sample with live call counters means the ok-rate series is + // entirely absent (zero successful probes in the window) → 0%. + const successPct = + probe.success != null + ? probe.success > 1 + ? probe.success + : probe.success * 100 + : 0; + if (successPct >= UNRESPONSIVE_MAX_SUCCESS_PCT) return null; + return { + name: p.name, + slug: p.slug, + tag: p.tag, + type: p.type, + layer: p.layer, + ms: { p50: 0, p90: 0, p99: 0, mean: 0 }, + successRate: successPct, + sampleSize: probe.sampleSize ?? undefined, + secondary: p.secondary, + availability: "unavailable", + unresponsive: true, + formula: p.formula, + }; +} + async function tryLoadLive( spec: Spec, isFiltered = false @@ -605,6 +674,22 @@ async function tryLoadLive( // unfiltered "All" view to avoid spamming logs on filtered views // where a missing provider is expected behavior. if (p50 == null || p90 == null || p99 == null) { + // Unresponsive cohort member: the latency series is gone from + // the window (failed probes record no latency, so a fully dead + // endpoint's percentiles go Prom-stale within 24h) but the call + // counters still prove probing continues. Keep the provider on + // the board as an unranked "unresponsive" row carrying its + // success rate + sample size instead of silently vanishing. + // Applies to filtered (region) variants too — the injected + // labels make the counters view-scoped, so a provider dead in + // only one region is flagged on that region's tab while ranking + // normally everywhere it still answers. + const unresponsive = unresponsiveResult(p, { success, sampleSize }); + if (unresponsive) { + liveResults.push(unresponsive); + if (sampleSize) totalSamples += sampleSize; + continue; + } if (!isFiltered && spec.status === "live") { const missing = [ p50 == null ? "p50" : null, @@ -685,7 +770,13 @@ async function tryLoadLive( } // No live numbers from anyone (every provider was skipped) → draft. - if (liveResults.length === 0) return null; + // Unresponsive rows don't count: a board made ONLY of dead providers + // has no ranking to publish, and letting them satisfy the check + // would also let them satisfy the quorum guard below during a Prom + // brownout (counters often survive a partial outage that kills the + // heavier percentile queries). + const rankedCount = liveResults.filter((r) => !r.unresponsive).length; + if (rankedCount === 0) return null; // Quorum guard. Providers whose p50/p90/p99 come back null are // silently skipped above, which is correct for a single flaky source @@ -706,9 +797,9 @@ async function tryLoadLive( // brownout scenario this guards against (1-2 stragglers passing // while the rest time out) stays caught. const quorum = Math.min(Math.ceil(declared / 2), 3); - if (liveResults.length < quorum) { + if (rankedCount < quorum) { console.warn( - `bench quorum fail: ${spec.slug} live=${liveResults.length}/${declared} → keeping previous render`, + `bench quorum fail: ${spec.slug} live=${rankedCount}/${declared} → keeping previous render`, ); return null; } diff --git a/src/lib/materialize/schema.ts b/src/lib/materialize/schema.ts index 9f4923e2..0a851f2b 100644 --- a/src/lib/materialize/schema.ts +++ b/src/lib/materialize/schema.ts @@ -11,7 +11,13 @@ import { z } from "zod"; import type { Benchmark } from "@/types/benchmark"; -export const MAT_SCHEMA_VERSION = 1; +// v2: ProviderResult gained `unresponsive` — cohort providers whose +// latency series went Prom-stale (failed probes record no latency) but +// whose call counters still return samples are published as unranked +// rows instead of being carried forward with dead latency values. +// Bumping republishes every blob under the new shape; deploy the worker +// BEFORE the site so v2 blobs exist when readers start asking for them. +export const MAT_SCHEMA_VERSION = 2; /** Key layout in the store (Upstash Redis, plain strings only: hash * fields cap at 32KB while strings allow 10MB requests). */ diff --git a/src/lib/rpc-hub-stats.ts b/src/lib/rpc-hub-stats.ts index 5600b7a1..88dd3856 100644 --- a/src/lib/rpc-hub-stats.ts +++ b/src/lib/rpc-hub-stats.ts @@ -68,6 +68,10 @@ export type RpcHubChain = { /** Bench display title (spec.title). */ benchTitle: string; providerCount: number; + /** Cohort providers currently flagged unresponsive on this chain + * (probed, all calls failing, no latency). Never part of + * best/fastest computations — display-only context. */ + unresponsiveCount?: number; best: RpcRegionBest | null; /** Best provider per probe region. */ regions: Partial>; @@ -195,6 +199,10 @@ async function buildChain(spec: Spec): Promise { const leader = rows[0]; const chain = spec.slug.replace(/-rpc$/, ""); const leaderSeries = bench.extras.series24h?.[leader.slug]; + // Unresponsive rows are excluded from `rows` by liveRows (they carry + // availability="unavailable" and zero latency), so they can't touch + // best/fastest — surface only their count for the chains table. + const unresponsiveCount = bench.results.filter((r) => r.unresponsive).length; return { chain, @@ -202,6 +210,7 @@ async function buildChain(spec: Spec): Promise { name: chainLabelForSlug(chain) ?? chain, benchTitle: spec.title, providerCount: rows.length, + ...(unresponsiveCount > 0 ? { unresponsiveCount } : {}), best: { provider: leader.slug, providerName: leader.name, p50Ms: round1(leader.ms.p50) }, regions, providers: rows.map((r) => ({ @@ -331,7 +340,8 @@ async function fetchRpcHubRaw(): Promise { const fetchRpcHubCached = unstable_cache( fetchRpcHubRaw, - ["rpc-hub-cohort-v1"], + // v2: chains gained unresponsiveCount (unresponsive provider rows). + ["rpc-hub-cohort-v2"], { revalidate: 60, tags: ["rpc-cohort"] }, ); diff --git a/src/lib/spec.ts b/src/lib/spec.ts index 24f3b89d..c1facee4 100644 --- a/src/lib/spec.ts +++ b/src/lib/spec.ts @@ -256,7 +256,10 @@ const loadBenchmarkUnfilteredCached = unstable_cache( // v18: RPC per-chain cluster (044-053) + rpc-capabilities lost its // perChainExplainer. Bench SET changed; without the bump, cached v17 // entries keep the removed explainer and miss the 10 new benches. - ["bench-unfiltered-v18"], + // v19: ProviderResult gained `unresponsive` (cohort providers with + // live call counters but no latency render as unranked badge rows). + // Cached v18 entries would silently drop dead providers instead. + ["bench-unfiltered-v19"], { revalidate: 300, tags: ["benchmarks"] }, ); @@ -400,7 +403,8 @@ const loadAllBenchmarksCached = unstable_cache( // this, sitemap/citable/products kept emitting the removed // rpc-capabilities/ variant URLs and missing the 10 new // -rpc benches after deploy. - ["all-benchmarks-v22"], + // v23: bumped with bench-unfiltered-v19 (unresponsive provider rows). + ["all-benchmarks-v23"], { revalidate: 300, tags: ["benchmarks"] }, ); export const loadAllBenchmarks = cache(loadAllBenchmarksCached); @@ -469,7 +473,9 @@ const loadBenchmarkFiltered = unstable_cache( // v8: bumped with bench-unfiltered-v11 (egress reduction). // v9: bumped with bench-unfiltered-v17 (slim cached objects). // v10: bumped with bench-unfiltered-v18 (RPC per-chain cluster). - ["bench-filters-v10"], + // v11: bumped with bench-unfiltered-v19 (unresponsive provider rows; + // region variants flag providers dead on that slice). + ["bench-filters-v11"], { revalidate: 300, tags: ["benchmarks"] } ); diff --git a/src/types/benchmark.ts b/src/types/benchmark.ts index 1a8392f1..4bb759e4 100644 --- a/src/types/benchmark.ts +++ b/src/types/benchmark.ts @@ -69,6 +69,17 @@ export type ProviderResult = { * p99 queries so the UI can render a soft offline state instead of * zero values. */ availability?: ProviderAvailability; + /** True when the provider is part of the bench's declared cohort and + * its reliability counters (spec `success` / `sample_size` queries, + * rpc_call_total-backed) still return samples for the current view, + * but no latency percentile exists — probes keep running and + * (nearly) all fail, so the latency series went Prom-stale. Rendered + * as an unranked, muted "unresponsive" row at the bottom of the + * ledger carrying its success rate, instead of silently vanishing + * from the leaderboard. Always paired with availability="unavailable" + * so every ranking surface (liveResults, hub best/fastest, best_name + * placeholders) keeps excluding it from winner claims. */ + unresponsive?: boolean; /** Carry-forward bookkeeping written by the materialization worker: * observedAt = epoch ms of the last successful Prom read behind these * numbers; staleSince = first failed cycle after it. Absent on data From a6e8aa531c1d8b9869e0477fb666f9c3b5b6574f Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:40:31 +0200 Subject: [PATCH 079/144] exclude infrastructure and harnesses sub-apps from site tsconfig (#894) Co-authored-by: Florent Tapponnier --- tsconfig.json | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index cf9c65d3..03db12d6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,5 +30,14 @@ ".next/dev/types/**/*.ts", "**/*.mts" ], - "exclude": ["node_modules"] + "exclude": [ + "node_modules", + // Standalone sub-apps (monitoring-ui, prom-admin) ship their own + // tsconfig with their own `@/*` alias. Type-checking them from the + // site's root config resolves their `@/lib/*` imports against the + // SITE's src/ and breaks `next build` (Cannot find module + // '@/lib/promote' — 2026-07-03 staging+prod deploy outage). + "infrastructure", + "harnesses" + ] } From bb582b36843ac8e94e33160f394c72bce5440cd7 Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Fri, 3 Jul 2026 22:14:20 +0200 Subject: [PATCH 080/144] add sortable Errors (24h) column to ledger table (#896) Co-authored-by: Florent Tapponnier --- src/components/ledger-table.tsx | 42 +++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/src/components/ledger-table.tsx b/src/components/ledger-table.tsx index eb627375..bee4be25 100644 --- a/src/components/ledger-table.tsx +++ b/src/components/ledger-table.tsx @@ -32,6 +32,15 @@ function isHexAddressSlug(slug: string): boolean { return HEX_ADDRESS_SLUG.test(slug.toLowerCase()); } +/** Absolute count of failed probes in the 24h window, derived from the + * fields the ledger already has (no new data): sample size × failure + * rate. Null when the row has no sampleSize (bench doesn't report it), + * rendered as "—" and sorted to the bottom. */ +function errorCount(r: ProviderResult): number | null { + if (r.sampleSize == null) return null; + return Math.round(r.sampleSize * (1 - r.successRate / 100)); +} + /** 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 @@ -48,6 +57,7 @@ type SortKey = | "value" | "delta" | "success" + | "errors" | "slot_p50" | `col_${number}`; @@ -215,6 +225,7 @@ export function LedgerTable({ if (k === "mean") return r.ms.mean; if (k === "delta") return deltaForSort(r); if (k === "success") return r.successRate ?? 0; + if (k === "errors") return errorCount(r) ?? -Infinity; if (k === "slot_p50") return r.slots?.p50 ?? Infinity; if (k.startsWith("col_")) { const idx = Number(k.slice(4)); @@ -360,7 +371,10 @@ export function LedgerTable({ {customCols ? colLabel(customCols[0]) : activePanel ? "Value" : "p50"} - + Reliability Trend @@ -483,6 +497,18 @@ export function LedgerTable({ > Success + + + Errors (24h) + + 24h {hasSlots && ( {r.successRate.toFixed(2)}% + + {errorCount(r)?.toLocaleString("en-US") ?? "—"} + — {hasSlots && ( @@ -872,6 +901,9 @@ function Row({ {r.successRate.toFixed(2)}% + + {errorCount(r)?.toLocaleString("en-US") ?? "—"} + Date: Fri, 3 Jul 2026 22:33:41 +0200 Subject: [PATCH 081/144] indexnow: diff-based hourly submissions instead of daily full batch (#898) Co-authored-by: Florent Tapponnier --- src/app/api/cron/indexnow/route.ts | 187 ++++++++++++++++++++++++----- vercel.json | 2 +- 2 files changed, 160 insertions(+), 29 deletions(-) diff --git a/src/app/api/cron/indexnow/route.ts b/src/app/api/cron/indexnow/route.ts index aa8856e9..a0c8a369 100644 --- a/src/app/api/cron/indexnow/route.ts +++ b/src/app/api/cron/indexnow/route.ts @@ -5,32 +5,73 @@ import { getProviderSlugs } from "@/lib/providers"; import { loadAllAlternatives } from "@/lib/alternatives"; import { SITE } from "@/data/site"; import { pingIndexNow } from "@/lib/indexnow"; +import { + readCohortSnapshot, + writeCohortSnapshot, +} from "@/lib/cohort-snapshot"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; /** - * Daily IndexNow ping. Submits every public URL (home, hubs, all live - * bench detail pages, provider pages, alternative pages) to the - * IndexNow endpoint so Bing / Yandex / Naver / Seznam re-crawl us in - * sub-minute. + * Hourly IndexNow ping, diff-based ("streaming" mode). + * + * Bing Webmaster Tools flags whole-site daily submissions as "IndexNow is + * in batch mode": submitting every URL regardless of change wastes crawl + * budget and dilutes the freshness signal. This route instead keeps a + * url → fingerprint map in KV and submits ONLY urls whose fingerprint + * changed since the previous run, plus urls that appeared or disappeared + * (the IndexNow spec asks for deleted urls too, so engines recrawl and + * observe the 410/redirect). + * + * Fingerprint inputs per URL family: + * - Bench pages: leader provider slug + rounded leader value + seoTitle + * ("the page's headline claim changed"). + * - Product / alternative pages: membership only (they change rarely; + * add/remove is the signal). + * - Static hubs: NEXT_PUBLIC_BUILD_TIME, so they are re-submitted once + * per deploy. * * Wiring (operator): * 1. Generate a 32-char hex key (`openssl rand -hex 16`). * 2. Put it in `public/.txt` (content = the key itself) and * `INDEXNOW_KEY` Vercel env var. * 3. `CRON_SECRET` Vercel env var gates this route. - * 4. vercel.json crons block calls this route once a day. + * 4. vercel.json crons block calls this route once an hour. * * Manual test: * curl https://openchainbench.com/api/cron/indexnow \ * -H "Authorization: Bearer ${CRON_SECRET}" * - * Returns `{ok, submitted, batches, message}`. When INDEXNOW_KEY is - * absent the route still 200s (dry-mode), so deploying this code - * without the env var doesn't break the cron schedule. + * Returns `{ok, submitted, batches, message, tracked, changed, added, + * removed, firstRun}`. When INDEXNOW_KEY is absent the ping is a no-op + * (dry-mode) and the fingerprint map is NOT persisted, so the first run + * with the key configured retries the same diff. */ +/** KV key for the url → fingerprint map (via cohort-snapshot helpers, + * final Upstash key: `ocb:cohort:indexnow-fingerprints:v1`). */ +const FINGERPRINT_KEY = "indexnow-fingerprints"; + +/** The cohort-snapshot reader defaults to a 10-minute staleness ceiling + * (tuned for live-metric blobs). Our map is state, not a cache: accept + * it up to the blob's own 24h TTL. If the blob does expire (cron dead + * for a day), the route degrades to first-run behavior below. */ +const FINGERPRINT_MAX_AGE_MS = 24 * 60 * 60 * 1000; + +type FingerprintMap = Record; + +/** FNV-1a 32-bit, hex-encoded. Not cryptographic — just a short stable + * digest so the KV map stays a few KB regardless of input length. */ +function shortHash(input: string): string { + let h = 0x811c9dc5; + for (let i = 0; i < input.length; i++) { + h ^= input.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return (h >>> 0).toString(16); +} + function isAuthorized(req: NextRequest): boolean { const secret = (process.env.CRON_SECRET ?? "").trim(); const header = (req.headers.get("authorization") ?? "").trim(); @@ -43,40 +84,130 @@ function isAuthorized(req: NextRequest): boolean { return timingSafeEqual(Buffer.from(header), Buffer.from(expected)); } -export async function GET(req: NextRequest) { - if (!isAuthorized(req)) { - return NextResponse.json({ error: "unauthorized" }, { status: 401 }); - } - +async function buildFingerprintMap(): Promise { const [benches, alternatives, providerSlugs] = await Promise.all([ getBenchmarks(), loadAllAlternatives(), getProviderSlugs(), ]); - const urls = new Set(); - urls.add(SITE.url); - urls.add(`${SITE.url}/benchmarks`); - urls.add(`${SITE.url}/products`); - urls.add(`${SITE.url}/methodology`); - urls.add(`${SITE.url}/about`); - urls.add(`${SITE.url}/mcp`); + const map: FingerprintMap = {}; + + // Static hubs: fingerprinted on the deploy timestamp so each deploy + // re-submits them exactly once. + const deployFp = shortHash(process.env.NEXT_PUBLIC_BUILD_TIME ?? "static"); + const hubs = [ + "", + "/benchmarks", + "/products", + "/methodology", + "/about", + "/mcp", + "/rpc", + "/prediction-markets", + "/hyperliquid", + "/perps", + "/chains", + ]; + for (const path of hubs) { + map[`${SITE.url}${path}`] = deployFp; + } + + // Bench pages: fingerprint = the headline claim (leader slug + rounded + // leader p50 + seoTitle). Rounding to 0.1 units keeps sub-noise metric + // wobble from triggering a resubmission every hour. for (const b of benches) { if (b.editorialStatus !== "live") continue; - urls.add(`${SITE.url}/benchmarks/${b.slug}`); + const leader = b.results[0]; + const fp = leader + ? `${leader.slug}:${Math.round(leader.ms.p50 * 10)}:${b.seoTitle ?? ""}` + : `no-leader:${b.seoTitle ?? ""}`; + map[`${SITE.url}/benchmarks/${b.slug}`] = shortHash(fp); } + + // Product / alternative pages change rarely; membership add/remove is + // the signal, so a constant fingerprint suffices. for (const slug of providerSlugs) { - urls.add(`${SITE.url}/products/${slug}`); + map[`${SITE.url}/products/${slug}`] = "exists"; } for (const alt of alternatives) { - urls.add(`${SITE.url}/alternatives/${alt.slug}`); + map[`${SITE.url}/alternatives/${alt.slug}`] = "exists"; } - const host = new URL(SITE.url).host; - const result = await pingIndexNow([...urls], { host }); + return map; +} - return NextResponse.json(result, { - status: result.ok ? 200 : 502, - headers: { "cache-control": "no-store" }, +export async function GET(req: NextRequest) { + if (!isAuthorized(req)) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + + const [next, prevSnap] = await Promise.all([ + buildFingerprintMap(), + readCohortSnapshot( + FINGERPRINT_KEY, + FINGERPRINT_MAX_AGE_MS, + ), + ]); + + // First run (no previous map in KV): submit NOTHING, just store the + // map. Submitting here would be one final mega-batch of every url — + // exactly the batch-mode pattern this route exists to avoid. From the + // next run on, only real diffs go out. + if (!prevSnap?.data) { + await writeCohortSnapshot(FINGERPRINT_KEY, next); + return NextResponse.json( + { + ok: true, + submitted: 0, + batches: 0, + message: "first run: stored fingerprint baseline, submitted nothing", + tracked: Object.keys(next).length, + changed: 0, + added: 0, + removed: 0, + firstRun: true, + }, + { headers: { "cache-control": "no-store" } }, + ); + } + + const prev = prevSnap.data; + const changed: string[] = []; + const added: string[] = []; + const removed: string[] = []; + for (const [url, fp] of Object.entries(next)) { + if (!(url in prev)) added.push(url); + else if (prev[url] !== fp) changed.push(url); + } + for (const url of Object.keys(prev)) { + if (!(url in next)) removed.push(url); + } + + const host = new URL(SITE.url).host; + const result = await pingIndexNow([...changed, ...added, ...removed], { + host, }); + + // Persist the new map only after a successful ping (an empty diff + // counts as success and refreshes the blob's TTL). On failure the old + // map stays, so the next run retries the same diff. + if (result.ok) { + await writeCohortSnapshot(FINGERPRINT_KEY, next); + } + + return NextResponse.json( + { + ...result, + tracked: Object.keys(next).length, + changed: changed.length, + added: added.length, + removed: removed.length, + firstRun: false, + }, + { + status: result.ok ? 200 : 502, + headers: { "cache-control": "no-store" }, + }, + ); } diff --git a/vercel.json b/vercel.json index 7b2ecc38..809f6606 100644 --- a/vercel.json +++ b/vercel.json @@ -13,7 +13,7 @@ }, { "path": "/api/cron/indexnow", - "schedule": "30 4 * * *" + "schedule": "0 * * * *" } ] } From e1327ec700bddacdde13acc8f611896e323541cf Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Sat, 4 Jul 2026 00:44:42 +0200 Subject: [PATCH 082/144] add 12 long-tail per-chain RPC benches (055-066) (#900) Co-authored-by: Florent Tapponnier --- benchmarks/berachain-rpc.yml | 159 ++++++++++++++++++++ benchmarks/blast-rpc.yml | 159 ++++++++++++++++++++ benchmarks/celo-rpc.yml | 182 +++++++++++++++++++++++ benchmarks/cronos-rpc.yml | 159 ++++++++++++++++++++ benchmarks/fraxtal-rpc.yml | 159 ++++++++++++++++++++ benchmarks/gnosis-rpc.yml | 205 ++++++++++++++++++++++++++ benchmarks/moonbeam-rpc.yml | 182 +++++++++++++++++++++++ benchmarks/soneium-rpc.yml | 159 ++++++++++++++++++++ benchmarks/sonic-rpc.yml | 205 ++++++++++++++++++++++++++ benchmarks/taiko-rpc.yml | 159 ++++++++++++++++++++ benchmarks/unichain-rpc.yml | 182 +++++++++++++++++++++++ benchmarks/zksync-rpc.yml | 159 ++++++++++++++++++++ scripts/generate-rpc-chain-benches.py | 191 +++++++++++++++++++++++- src/lib/chains.ts | 77 +++++++++- src/lib/logo-manifest.ts | 10 ++ src/lib/spec.ts | 12 +- 16 files changed, 2351 insertions(+), 8 deletions(-) create mode 100644 benchmarks/berachain-rpc.yml create mode 100644 benchmarks/blast-rpc.yml create mode 100644 benchmarks/celo-rpc.yml create mode 100644 benchmarks/cronos-rpc.yml create mode 100644 benchmarks/fraxtal-rpc.yml create mode 100644 benchmarks/gnosis-rpc.yml create mode 100644 benchmarks/moonbeam-rpc.yml create mode 100644 benchmarks/soneium-rpc.yml create mode 100644 benchmarks/sonic-rpc.yml create mode 100644 benchmarks/taiko-rpc.yml create mode 100644 benchmarks/unichain-rpc.yml create mode 100644 benchmarks/zksync-rpc.yml diff --git a/benchmarks/berachain-rpc.yml b/benchmarks/berachain-rpc.yml new file mode 100644 index 00000000..989217d1 --- /dev/null +++ b/benchmarks/berachain-rpc.yml @@ -0,0 +1,159 @@ +# OpenChainBench. Bench № 062 + +slug: berachain-rpc +number: "062" +title: Fastest free Berachain RPC, live no-key endpoint latency +seo_title: "Fastest free Berachain RPC 2026" +seo_description: "{{best_name}} leads free Berachain RPC at {{best_p50}} (eth_blockNumber p50, 24h). 4 no-key providers measured every 15s from 3 regions." +subtitle: HTTP round-trip latency for eth_blockNumber against every free, no-key public Berachain RPC endpoint, audited every 15 seconds from 3 regions. + +category: RPCs +status: live +metric: RPC latency +unit: ms +higher_is_better: false + +seo_intro: | + Berachain's proof-of-liquidity L1 fields 4 no-key providers: the Foundation's `rpc.berachain.com` plus PublicNode, dRPC and Tenderly. A thin cohort is itself a signal, several sibling chains failed the cluster's four-keyless-provider bar entirely, so each endpoint that qualifies here carries more of the redundancy burden. Identical probes every 15 seconds, three regions, full response classification. + +abstract: | + Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_blockNumber`) against every no-key public Berachain endpoint that sustains continuous probing, 4 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, so the leaderboard rewards sustained, honest availability rather than a fast error message. The cross-chain view lives on the parent rpc-capabilities benchmark; this page is the Berachain-scoped answer with per-region breakdowns as a first-class dimension. + +methodology: + - "Cadence: every 15 seconds per provider, from each of 3 probe regions (us-east Virginia, eu-west Amsterdam, sgp Singapore). Headline p50/p90/p99 aggregate across all 3 regions via Prometheus `avg(quantile_over_time(...))`; per-region breakdowns are first-class on this page via the region tabs." + - "Payload: `{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_blockNumber\",\"params\":[]}`. Plain HTTP POST, identical for every endpoint, no API key in any request." + - "Latency: client-side round-trip delta in milliseconds, exposed as both a gauge and a histogram (buckets 50 ms → 10 s), so percentiles are computed via Prometheus `quantile_over_time` over the last 24 hours." + - "Call-result classification: `ok` (HTTP 200 + non-empty result), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 20 blocks behind the cross-provider tip), `timeout`. Latency without reliability is a misleading ranking signal." + - "Archive depth: every 5 minutes we issue `eth_getBalance` at (head − depth) for depths from Geth's default pruned cap up to 5M blocks, exposing which free endpoints actually serve historical state." + - "This page is part of the per-chain RPC cluster derived from the cross-chain [rpc-capabilities](https://openchainbench.com/benchmarks/rpc-capabilities) benchmark; the identical harness, methodology and exclusion rules apply on every chain." + - "Chain scope: every query on this page is pinned to chain=\"berachain\". Provider coverage: 4 no-key endpoints (PublicNode, dRPC, Tenderly, Berachain). Exclusions follow the cluster-wide rules documented on the parent benchmark." + +findings: + - "{{best_name}} currently leads free Berachain RPC at {{best_p50}} (`eth_blockNumber` p50, 24h) across 4 measured providers." + - "{{name:drpc}} ({{p50:drpc}}) extends its expansion-wide consistency run to Berachain, the anycast profile that takes 10 of the 12 long-tail chains on the 3-region average." + - "{{name:tenderly}} shows the long-tail single-origin signature again, roughly 330 ms from every region, a sharp contrast with its performance on the chains its edge network actually fronts." + - "The Foundation endpoint gives the chain a credible house baseline; whether it beats the gateways depends on your origin, which is exactly what the per-region tabs are for." + +faq: + - q: "What is the fastest free Berachain RPC right now?" + a: "{{best_name}} currently leads at {{best_p50}} (`eth_blockNumber` p50 over the last 24h), measured against 4 no-key providers probed every 15 seconds from us-east, eu-west and Singapore. The leaderboard re-sorts continuously against fresh Prometheus samples, so the answer on this page is the answer right now, not a quarterly snapshot. Use the region tabs to see the leader from the origin closest to your deployment." + - q: "Which Berachain RPCs work without an API key?" + a: "The 4 providers on this page: PublicNode, dRPC, Tenderly, Berachain. Every (provider, chain) pair was live-verified no-key before inclusion, and anything that key-gates, region-blocks or rate-limits below our 15-second cadence is excluded rather than listed with an asterisk." + - q: "Does the fastest Berachain RPC change by region?" + a: "Frequently. The headline number averages three probe origins (us-east, eu-west, Singapore), but per-region leaders regularly diverge, a gateway that wins from Virginia can lose from Singapore by multiples. The region tabs at the top of the page re-scope every number on the page to a single origin; pick the one closest to where your requests actually originate." + - q: "How is Berachain RPC latency measured here?" + a: "One identical JSON-RPC POST (`eth_blockNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. Wall-clock round-trip is recorded at millisecond precision; p50/p90/p99 are computed via Prometheus `quantile_over_time` over 24 hours. Responses are classified (`ok` / `http_err` / `jsonrpc_err` / `stale` / `timeout`) so an endpoint stuck on an old head or returning errors behind HTTP 200 is never ranked as fastest. The harness is open source and every number on this page is a public Prometheus query you can run yourself." + - q: "Are free Berachain RPCs ready for production traffic?" + a: "The four qualifying endpoints all sustain our 15-second cadence with high measured success rates, which is the floor for production reads. The real constraint is redundancy: with 4 providers, one incident removes a quarter of your options, so run the current leader ({{best_name}}, {{best_p50}}) as primary with the runner-up wired as fallback and let this page arbitrate after incidents." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/rpc-capabilities + +prometheus: + window: 24h + freshness_metric: rpc_latency_milliseconds + +# Per-cell (region) ranking matrix for scoped badge claims. Chain is +# fixed for the whole bench, so cells key on region alone. +rank_matrix_query: avg by (provider, region) (ocb:rpc_latency_milliseconds:p50_24h{chain="berachain"}) + +# Region is the only dimension: chain is baked into every query. +dimensions: + region: + - { value: all, label: All regions } + - { value: us-east, label: US-East } + - { value: eu-west, label: EU-West } + - { value: sgp, label: Singapore } + +providers: + - slug: publicnode + name: PublicNode + tag: Allnodes-operated, 70+ chains, archive on most + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Berachain endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="berachain"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="publicnode", chain="berachain"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="publicnode", chain="berachain"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="publicnode", chain="berachain"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="publicnode", chain="berachain"}) / sum(ocb:rpc_call:rate_24h{provider="publicnode", chain="berachain"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="publicnode", chain="berachain"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="berachain"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="berachain", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="berachain", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="berachain", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="berachain", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="berachain", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="berachain", region="sgp"}[1h]) + + - slug: drpc + name: dRPC + tag: Decentralized RPC mesh, consensus-checked + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Berachain endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="berachain"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="berachain"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="drpc", chain="berachain"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="drpc", chain="berachain"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="drpc", chain="berachain"}) / sum(ocb:rpc_call:rate_24h{provider="drpc", chain="berachain"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="drpc", chain="berachain"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="berachain"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="berachain", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="berachain", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="berachain", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="berachain", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="berachain", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="berachain", region="sgp"}[1h]) + + - slug: tenderly + name: Tenderly + tag: Multi-chain public gateway, no key + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key Berachain endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="berachain"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="tenderly", chain="berachain"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="tenderly", chain="berachain"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="tenderly", chain="berachain"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="tenderly", chain="berachain"}) / sum(ocb:rpc_call:rate_24h{provider="tenderly", chain="berachain"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="tenderly", chain="berachain"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="berachain"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="berachain", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="berachain", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="berachain", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="berachain", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="berachain", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="berachain", region="sgp"}[1h]) + + - slug: berachain-official + name: Berachain + tag: Berachain Foundation public RPC, Berachain only + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Berachain's no-key Berachain endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="berachain-official", chain="berachain"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="berachain-official", chain="berachain"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="berachain-official", chain="berachain"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="berachain-official", chain="berachain"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="berachain-official", chain="berachain"}) / sum(ocb:rpc_call:rate_24h{provider="berachain-official", chain="berachain"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="berachain-official", chain="berachain"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="berachain-official", chain="berachain"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="berachain-official", chain="berachain", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="berachain-official", chain="berachain", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="berachain-official", chain="berachain", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="berachain-official", chain="berachain", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="berachain-official", chain="berachain", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="berachain-official", chain="berachain", region="sgp"}[1h]) + diff --git a/benchmarks/blast-rpc.yml b/benchmarks/blast-rpc.yml new file mode 100644 index 00000000..8ef26c83 --- /dev/null +++ b/benchmarks/blast-rpc.yml @@ -0,0 +1,159 @@ +# OpenChainBench. Bench № 060 + +slug: blast-rpc +number: "060" +title: Fastest free Blast RPC, live no-key endpoint latency +seo_title: "Fastest free Blast RPC 2026" +seo_description: "{{best_name}} leads free Blast RPC at {{best_p50}} (eth_blockNumber p50, 24h). 4 no-key providers measured every 15s from 3 regions." +subtitle: HTTP round-trip latency for eth_blockNumber against every free, no-key public Blast RPC endpoint, audited every 15 seconds from 3 regions. + +category: RPCs +status: live +metric: RPC latency +unit: ms +higher_is_better: false + +seo_intro: | + Blast is the expansion's measurement cautionary tale. The chain-official `rpc.blast.io` answers in roughly 2 ms from all three probe regions at once, which no single origin can do: Virginia, Amsterdam and Singapore are separated by 80+ ms round trips at the speed of light. The endpoint terminates at an edge network. Our stale-head detection confirms the blocks it serves are fresh, but a sub-5 ms number measures the edge handshake, not the chain. 4 no-key providers, identical probes, three regions. + +abstract: | + Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_blockNumber`) against every no-key public Blast endpoint that sustains continuous probing, 4 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, so the leaderboard rewards sustained, honest availability rather than a fast error message. The cross-chain view lives on the parent rpc-capabilities benchmark; this page is the Blast-scoped answer with per-region breakdowns as a first-class dimension. + +methodology: + - "Cadence: every 15 seconds per provider, from each of 3 probe regions (us-east Virginia, eu-west Amsterdam, sgp Singapore). Headline p50/p90/p99 aggregate across all 3 regions via Prometheus `avg(quantile_over_time(...))`; per-region breakdowns are first-class on this page via the region tabs." + - "Payload: `{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_blockNumber\",\"params\":[]}`. Plain HTTP POST, identical for every endpoint, no API key in any request." + - "Latency: client-side round-trip delta in milliseconds, exposed as both a gauge and a histogram (buckets 50 ms → 10 s), so percentiles are computed via Prometheus `quantile_over_time` over the last 24 hours." + - "Call-result classification: `ok` (HTTP 200 + non-empty result), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 20 blocks behind the cross-provider tip), `timeout`. Latency without reliability is a misleading ranking signal." + - "Archive depth: every 5 minutes we issue `eth_getBalance` at (head − depth) for depths from Geth's default pruned cap up to 5M blocks, exposing which free endpoints actually serve historical state." + - "This page is part of the per-chain RPC cluster derived from the cross-chain [rpc-capabilities](https://openchainbench.com/benchmarks/rpc-capabilities) benchmark; the identical harness, methodology and exclusion rules apply on every chain." + - "Chain scope: every query on this page is pinned to chain=\"blast\". Provider coverage: 4 no-key endpoints (PublicNode, dRPC, Tenderly, Blast). Exclusions follow the cluster-wide rules documented on the parent benchmark." + +findings: + - "{{best_name}} currently leads free Blast RPC at {{best_p50}} (`eth_blockNumber` p50, 24h) across 4 measured providers." + - "`rpc.blast.io` posts ~2 ms in every region simultaneously, physically impossible for one origin. Read it as an edge-terminated endpoint: heads are fresh per our stale detection, but the latency column measures CDN termination rather than a node round trip, the same class of caution we document for Cloudflare-eth." + - "{{name:drpc}} ({{p50:drpc}}) is the honest-infrastructure comparison point: anycast consistency across the three regions with real node round trips behind it, the profile that wins it 10 of the 12 expansion chains." + - "{{name:tenderly}} sits at the expansion's familiar flat ~330 ms in all regions on Blast, single-origin routing on a gateway that is genuinely quick on the major chains." + +faq: + - q: "What is the fastest free Blast RPC right now?" + a: "{{best_name}} currently leads at {{best_p50}} (`eth_blockNumber` p50 over the last 24h), measured against 4 no-key providers probed every 15 seconds from us-east, eu-west and Singapore. The leaderboard re-sorts continuously against fresh Prometheus samples, so the answer on this page is the answer right now, not a quarterly snapshot. Use the region tabs to see the leader from the origin closest to your deployment." + - q: "Which Blast RPCs work without an API key?" + a: "The 4 providers on this page: PublicNode, dRPC, Tenderly, Blast. Every (provider, chain) pair was live-verified no-key before inclusion, and anything that key-gates, region-blocks or rate-limits below our 15-second cadence is excluded rather than listed with an asterisk." + - q: "Does the fastest Blast RPC change by region?" + a: "Frequently. The headline number averages three probe origins (us-east, eu-west, Singapore), but per-region leaders regularly diverge, a gateway that wins from Virginia can lose from Singapore by multiples. The region tabs at the top of the page re-scope every number on the page to a single origin; pick the one closest to where your requests actually originate." + - q: "How is Blast RPC latency measured here?" + a: "One identical JSON-RPC POST (`eth_blockNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. Wall-clock round-trip is recorded at millisecond precision; p50/p90/p99 are computed via Prometheus `quantile_over_time` over 24 hours. Responses are classified (`ok` / `http_err` / `jsonrpc_err` / `stale` / `timeout`) so an endpoint stuck on an old head or returning errors behind HTTP 200 is never ranked as fastest. The harness is open source and every number on this page is a public Prometheus query you can run yourself." + - q: "Is rpc.blast.io really that fast, or is something else going on?" + a: "Something else. Two milliseconds simultaneously from Virginia, Amsterdam and Singapore is below the physical round-trip floor for any single origin, so the endpoint is answering at an anycast/CDN edge. Our stale-head detection shows the blocks it returns are current, so it is not serving a stale cache today, but edge termination means the latency figure describes the edge network, not node processing. We keep it ranked with the caveat documented, exactly as we do for Cloudflare's fast-but-permissioned Ethereum endpoint." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/rpc-capabilities + +prometheus: + window: 24h + freshness_metric: rpc_latency_milliseconds + +# Per-cell (region) ranking matrix for scoped badge claims. Chain is +# fixed for the whole bench, so cells key on region alone. +rank_matrix_query: avg by (provider, region) (ocb:rpc_latency_milliseconds:p50_24h{chain="blast"}) + +# Region is the only dimension: chain is baked into every query. +dimensions: + region: + - { value: all, label: All regions } + - { value: us-east, label: US-East } + - { value: eu-west, label: EU-West } + - { value: sgp, label: Singapore } + +providers: + - slug: publicnode + name: PublicNode + tag: Allnodes-operated, 70+ chains, archive on most + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Blast endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="blast"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="publicnode", chain="blast"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="publicnode", chain="blast"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="publicnode", chain="blast"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="publicnode", chain="blast"}) / sum(ocb:rpc_call:rate_24h{provider="publicnode", chain="blast"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="publicnode", chain="blast"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="blast"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="blast", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="blast", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="blast", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="blast", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="blast", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="blast", region="sgp"}[1h]) + + - slug: drpc + name: dRPC + tag: Decentralized RPC mesh, consensus-checked + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Blast endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="blast"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="blast"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="drpc", chain="blast"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="drpc", chain="blast"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="drpc", chain="blast"}) / sum(ocb:rpc_call:rate_24h{provider="drpc", chain="blast"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="drpc", chain="blast"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="blast"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="blast", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="blast", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="blast", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="blast", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="blast", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="blast", region="sgp"}[1h]) + + - slug: tenderly + name: Tenderly + tag: Multi-chain public gateway, no key + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key Blast endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="blast"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="tenderly", chain="blast"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="tenderly", chain="blast"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="tenderly", chain="blast"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="tenderly", chain="blast"}) / sum(ocb:rpc_call:rate_24h{provider="tenderly", chain="blast"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="tenderly", chain="blast"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="blast"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="blast", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="blast", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="blast", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="blast", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="blast", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="blast", region="sgp"}[1h]) + + - slug: blast-official + name: Blast + tag: Chain-official RPC, edge-terminated (see findings) + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Blast's no-key Blast endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="blast-official", chain="blast"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="blast-official", chain="blast"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="blast-official", chain="blast"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="blast-official", chain="blast"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="blast-official", chain="blast"}) / sum(ocb:rpc_call:rate_24h{provider="blast-official", chain="blast"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="blast-official", chain="blast"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="blast-official", chain="blast"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="blast-official", chain="blast", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="blast-official", chain="blast", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="blast-official", chain="blast", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="blast-official", chain="blast", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="blast-official", chain="blast", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="blast-official", chain="blast", region="sgp"}[1h]) + diff --git a/benchmarks/celo-rpc.yml b/benchmarks/celo-rpc.yml new file mode 100644 index 00000000..6ff76e77 --- /dev/null +++ b/benchmarks/celo-rpc.yml @@ -0,0 +1,182 @@ +# OpenChainBench. Bench № 057 + +slug: celo-rpc +number: "057" +title: Fastest free Celo RPC, live no-key endpoint latency +seo_title: "Fastest free Celo RPC 2026" +seo_description: "{{best_name}} leads free Celo RPC at {{best_p50}} (eth_blockNumber p50, 24h). 5 no-key providers measured every 15s from 3 regions." +subtitle: HTTP round-trip latency for eth_blockNumber against every free, no-key public Celo RPC endpoint, audited every 15 seconds from 3 regions. + +category: RPCs +status: live +metric: RPC latency +unit: ms +higher_is_better: false + +seo_intro: | + Celo brings 5 no-key providers anchored by Forno (`forno.celo.org`), cLabs' public endpoint that predates most of the gateway industry. Since Celo's migration to an Ethereum L2 the RPC surface is standard EVM, so the multi-chain gateways (PublicNode, dRPC, 1RPC, Tenderly) compete directly with the house endpoint on the identical `eth_blockNumber` probe every 15 seconds from three regions. + +abstract: | + Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_blockNumber`) against every no-key public Celo endpoint that sustains continuous probing, 5 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, so the leaderboard rewards sustained, honest availability rather than a fast error message. The cross-chain view lives on the parent rpc-capabilities benchmark; this page is the Celo-scoped answer with per-region breakdowns as a first-class dimension. + +methodology: + - "Cadence: every 15 seconds per provider, from each of 3 probe regions (us-east Virginia, eu-west Amsterdam, sgp Singapore). Headline p50/p90/p99 aggregate across all 3 regions via Prometheus `avg(quantile_over_time(...))`; per-region breakdowns are first-class on this page via the region tabs." + - "Payload: `{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_blockNumber\",\"params\":[]}`. Plain HTTP POST, identical for every endpoint, no API key in any request." + - "Latency: client-side round-trip delta in milliseconds, exposed as both a gauge and a histogram (buckets 50 ms → 10 s), so percentiles are computed via Prometheus `quantile_over_time` over the last 24 hours." + - "Call-result classification: `ok` (HTTP 200 + non-empty result), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 20 blocks behind the cross-provider tip), `timeout`. Latency without reliability is a misleading ranking signal." + - "Archive depth: every 5 minutes we issue `eth_getBalance` at (head − depth) for depths from Geth's default pruned cap up to 5M blocks, exposing which free endpoints actually serve historical state." + - "This page is part of the per-chain RPC cluster derived from the cross-chain [rpc-capabilities](https://openchainbench.com/benchmarks/rpc-capabilities) benchmark; the identical harness, methodology and exclusion rules apply on every chain." + - "Chain scope: every query on this page is pinned to chain=\"celo\". Provider coverage: 5 no-key endpoints (PublicNode, dRPC, 1RPC, Tenderly, Celo (Forno)). Exclusions follow the cluster-wide rules documented on the parent benchmark." + +findings: + - "{{best_name}} currently leads free Celo RPC at {{best_p50}} (`eth_blockNumber` p50, 24h) across 5 measured providers." + - "{{name:drpc}} ({{p50:drpc}}) extends its long-tail run here, 10 of the 12 expansion chains fall to it on the 3-region average, a consistency win built on anycast rather than any single-region record." + - "Forno remains a serviceable default years after launch, but it is one origin: at least two of our three probe regions always see it with an ocean in the path, which the region tabs make visible." + - "{{name:tenderly}} shows the same long-tail collapse measured across this expansion: ~330 ms flat in all three regions, single-origin routing behind a gateway that is genuinely fast on the majors." + +faq: + - q: "What is the fastest free Celo RPC right now?" + a: "{{best_name}} currently leads at {{best_p50}} (`eth_blockNumber` p50 over the last 24h), measured against 5 no-key providers probed every 15 seconds from us-east, eu-west and Singapore. The leaderboard re-sorts continuously against fresh Prometheus samples, so the answer on this page is the answer right now, not a quarterly snapshot. Use the region tabs to see the leader from the origin closest to your deployment." + - q: "Which Celo RPCs work without an API key?" + a: "The 5 providers on this page: PublicNode, dRPC, 1RPC, Tenderly, Celo (Forno). Every (provider, chain) pair was live-verified no-key before inclusion, and anything that key-gates, region-blocks or rate-limits below our 15-second cadence is excluded rather than listed with an asterisk." + - q: "Does the fastest Celo RPC change by region?" + a: "Frequently. The headline number averages three probe origins (us-east, eu-west, Singapore), but per-region leaders regularly diverge, a gateway that wins from Virginia can lose from Singapore by multiples. The region tabs at the top of the page re-scope every number on the page to a single origin; pick the one closest to where your requests actually originate." + - q: "How is Celo RPC latency measured here?" + a: "One identical JSON-RPC POST (`eth_blockNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. Wall-clock round-trip is recorded at millisecond precision; p50/p90/p99 are computed via Prometheus `quantile_over_time` over 24 hours. Responses are classified (`ok` / `http_err` / `jsonrpc_err` / `stale` / `timeout`) so an endpoint stuck on an old head or returning errors behind HTTP 200 is never ranked as fastest. The harness is open source and every number on this page is a public Prometheus query you can run yourself." + - q: "Is Forno still the right default RPC for Celo?" + a: "Forno is stable and honest, but it is a single origin, so at least two of our three probe regions always pay cross-ocean latency to reach it. The current leader above ({{best_name}} at {{best_p50}}) reflects the 3-region average; if your traffic is single-region, open that region's tab, Forno's ranking moves markedly by origin." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/rpc-capabilities + +prometheus: + window: 24h + freshness_metric: rpc_latency_milliseconds + +# Per-cell (region) ranking matrix for scoped badge claims. Chain is +# fixed for the whole bench, so cells key on region alone. +rank_matrix_query: avg by (provider, region) (ocb:rpc_latency_milliseconds:p50_24h{chain="celo"}) + +# Region is the only dimension: chain is baked into every query. +dimensions: + region: + - { value: all, label: All regions } + - { value: us-east, label: US-East } + - { value: eu-west, label: EU-West } + - { value: sgp, label: Singapore } + +providers: + - slug: publicnode + name: PublicNode + tag: Allnodes-operated, 70+ chains, archive on most + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Celo endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="celo"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="publicnode", chain="celo"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="publicnode", chain="celo"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="publicnode", chain="celo"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="publicnode", chain="celo"}) / sum(ocb:rpc_call:rate_24h{provider="publicnode", chain="celo"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="publicnode", chain="celo"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="celo"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="celo", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="celo", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="celo", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="celo", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="celo", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="celo", region="sgp"}[1h]) + + - slug: drpc + name: dRPC + tag: Decentralized RPC mesh, consensus-checked + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Celo endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="celo"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="celo"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="drpc", chain="celo"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="drpc", chain="celo"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="drpc", chain="celo"}) / sum(ocb:rpc_call:rate_24h{provider="drpc", chain="celo"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="drpc", chain="celo"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="celo"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="celo", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="celo", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="celo", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="celo", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="celo", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="celo", region="sgp"}[1h]) + + - slug: 1rpc + name: 1RPC + tag: Privacy-preserving gateway by Automata Network + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to 1RPC's no-key Celo endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="celo"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="1rpc", chain="celo"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="1rpc", chain="celo"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="1rpc", chain="celo"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="1rpc", chain="celo"}) / sum(ocb:rpc_call:rate_24h{provider="1rpc", chain="celo"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="1rpc", chain="celo"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="1rpc", chain="celo"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="celo", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="1rpc", chain="celo", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="celo", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="1rpc", chain="celo", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="celo", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="1rpc", chain="celo", region="sgp"}[1h]) + + - slug: tenderly + name: Tenderly + tag: Multi-chain public gateway, no key + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key Celo endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="celo"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="tenderly", chain="celo"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="tenderly", chain="celo"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="tenderly", chain="celo"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="tenderly", chain="celo"}) / sum(ocb:rpc_call:rate_24h{provider="tenderly", chain="celo"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="tenderly", chain="celo"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="celo"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="celo", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="celo", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="celo", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="celo", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="celo", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="celo", region="sgp"}[1h]) + + - slug: celo-official + name: Celo (Forno) + tag: cLabs Forno public RPC, Celo mainnet only + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Celo (Forno)'s no-key Celo endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="celo-official", chain="celo"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="celo-official", chain="celo"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="celo-official", chain="celo"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="celo-official", chain="celo"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="celo-official", chain="celo"}) / sum(ocb:rpc_call:rate_24h{provider="celo-official", chain="celo"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="celo-official", chain="celo"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="celo-official", chain="celo"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="celo-official", chain="celo", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="celo-official", chain="celo", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="celo-official", chain="celo", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="celo-official", chain="celo", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="celo-official", chain="celo", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="celo-official", chain="celo", region="sgp"}[1h]) + diff --git a/benchmarks/cronos-rpc.yml b/benchmarks/cronos-rpc.yml new file mode 100644 index 00000000..b6408bc6 --- /dev/null +++ b/benchmarks/cronos-rpc.yml @@ -0,0 +1,159 @@ +# OpenChainBench. Bench № 064 + +slug: cronos-rpc +number: "064" +title: Fastest free Cronos RPC, live no-key endpoint latency +seo_title: "Fastest free Cronos RPC 2026" +seo_description: "{{best_name}} leads free Cronos RPC at {{best_p50}} (eth_blockNumber p50, 24h). 4 no-key providers measured every 15s from 3 regions." +subtitle: HTTP round-trip latency for eth_blockNumber against every free, no-key public Cronos RPC endpoint, audited every 15 seconds from 3 regions. + +category: RPCs +status: live +metric: RPC latency +unit: ms +higher_is_better: false + +seo_intro: | + Cronos fields 4 no-key providers and encodes two integration traps from our sweep: PublicNode serves the chain at `cronos-evm-rpc.publicnode.com` (the intuitive `cronos-rpc` subdomain resolves but returns non-JSON), and 1RPC uses the ticker path `1rpc.io/cro`. Tenderly's public gateway does not reach Cronos, making this one of the few pages in the cluster without it. Probes every 15 seconds from three regions. + +abstract: | + Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_blockNumber`) against every no-key public Cronos endpoint that sustains continuous probing, 4 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, so the leaderboard rewards sustained, honest availability rather than a fast error message. The cross-chain view lives on the parent rpc-capabilities benchmark; this page is the Cronos-scoped answer with per-region breakdowns as a first-class dimension. + +methodology: + - "Cadence: every 15 seconds per provider, from each of 3 probe regions (us-east Virginia, eu-west Amsterdam, sgp Singapore). Headline p50/p90/p99 aggregate across all 3 regions via Prometheus `avg(quantile_over_time(...))`; per-region breakdowns are first-class on this page via the region tabs." + - "Payload: `{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_blockNumber\",\"params\":[]}`. Plain HTTP POST, identical for every endpoint, no API key in any request." + - "Latency: client-side round-trip delta in milliseconds, exposed as both a gauge and a histogram (buckets 50 ms → 10 s), so percentiles are computed via Prometheus `quantile_over_time` over the last 24 hours." + - "Call-result classification: `ok` (HTTP 200 + non-empty result), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 20 blocks behind the cross-provider tip), `timeout`. Latency without reliability is a misleading ranking signal." + - "Archive depth: every 5 minutes we issue `eth_getBalance` at (head − depth) for depths from Geth's default pruned cap up to 5M blocks, exposing which free endpoints actually serve historical state." + - "This page is part of the per-chain RPC cluster derived from the cross-chain [rpc-capabilities](https://openchainbench.com/benchmarks/rpc-capabilities) benchmark; the identical harness, methodology and exclusion rules apply on every chain." + - "Chain scope: every query on this page is pinned to chain=\"cronos\". Provider coverage: 4 no-key endpoints (PublicNode, dRPC, 1RPC, Cronos). Exclusions follow the cluster-wide rules documented on the parent benchmark." + +findings: + - "{{best_name}} currently leads free Cronos RPC at {{best_p50}} (`eth_blockNumber` p50, 24h) across 4 measured providers." + - "{{name:publicnode}} ({{p50:publicnode}}) hides behind a naming trap: the working subdomain is `cronos-evm-rpc`, while `cronos-rpc` resolves and then returns non-JSON, a failure mode that looks like an outage if you guessed the URL." + - "{{name:drpc}} ({{p50:drpc}}) delivers its usual three-region steadiness, the anycast pattern behind its 10-of-12 record across this long-tail expansion." + - "MeowRPC, historically listed for Cronos in RPC directories, is absent by measurement: its long-tail DNS is gone and the provider appears defunct outside a handful of legacy chains." + +faq: + - q: "What is the fastest free Cronos RPC right now?" + a: "{{best_name}} currently leads at {{best_p50}} (`eth_blockNumber` p50 over the last 24h), measured against 4 no-key providers probed every 15 seconds from us-east, eu-west and Singapore. The leaderboard re-sorts continuously against fresh Prometheus samples, so the answer on this page is the answer right now, not a quarterly snapshot. Use the region tabs to see the leader from the origin closest to your deployment." + - q: "Which Cronos RPCs work without an API key?" + a: "The 4 providers on this page: PublicNode, dRPC, 1RPC, Cronos. Every (provider, chain) pair was live-verified no-key before inclusion, and anything that key-gates, region-blocks or rate-limits below our 15-second cadence is excluded rather than listed with an asterisk." + - q: "Does the fastest Cronos RPC change by region?" + a: "Frequently. The headline number averages three probe origins (us-east, eu-west, Singapore), but per-region leaders regularly diverge, a gateway that wins from Virginia can lose from Singapore by multiples. The region tabs at the top of the page re-scope every number on the page to a single origin; pick the one closest to where your requests actually originate." + - q: "How is Cronos RPC latency measured here?" + a: "One identical JSON-RPC POST (`eth_blockNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. Wall-clock round-trip is recorded at millisecond precision; p50/p90/p99 are computed via Prometheus `quantile_over_time` over 24 hours. Responses are classified (`ok` / `http_err` / `jsonrpc_err` / `stale` / `timeout`) so an endpoint stuck on an old head or returning errors behind HTTP 200 is never ranked as fastest. The harness is open source and every number on this page is a public Prometheus query you can run yourself." + - q: "Why isn't MeowRPC listed on Cronos?" + a: "We tried it. MeowRPC's long-tail endpoints no longer resolve (DNS gone), and the provider appears defunct outside a few legacy chains, so it failed the live verification, eth_chainId match plus sustained probing, that gates admission to this cluster. Directories still listing it are copying stale metadata; this bench only ranks endpoints that answer." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/rpc-capabilities + +prometheus: + window: 24h + freshness_metric: rpc_latency_milliseconds + +# Per-cell (region) ranking matrix for scoped badge claims. Chain is +# fixed for the whole bench, so cells key on region alone. +rank_matrix_query: avg by (provider, region) (ocb:rpc_latency_milliseconds:p50_24h{chain="cronos"}) + +# Region is the only dimension: chain is baked into every query. +dimensions: + region: + - { value: all, label: All regions } + - { value: us-east, label: US-East } + - { value: eu-west, label: EU-West } + - { value: sgp, label: Singapore } + +providers: + - slug: publicnode + name: PublicNode + tag: Allnodes-operated, 70+ chains, archive on most + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Cronos endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="cronos"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="publicnode", chain="cronos"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="publicnode", chain="cronos"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="publicnode", chain="cronos"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="publicnode", chain="cronos"}) / sum(ocb:rpc_call:rate_24h{provider="publicnode", chain="cronos"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="publicnode", chain="cronos"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="cronos"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="cronos", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="cronos", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="cronos", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="cronos", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="cronos", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="cronos", region="sgp"}[1h]) + + - slug: drpc + name: dRPC + tag: Decentralized RPC mesh, consensus-checked + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Cronos endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="cronos"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="cronos"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="drpc", chain="cronos"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="drpc", chain="cronos"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="drpc", chain="cronos"}) / sum(ocb:rpc_call:rate_24h{provider="drpc", chain="cronos"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="drpc", chain="cronos"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="cronos"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="cronos", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="cronos", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="cronos", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="cronos", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="cronos", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="cronos", region="sgp"}[1h]) + + - slug: 1rpc + name: 1RPC + tag: Privacy-preserving gateway by Automata Network + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to 1RPC's no-key Cronos endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="cronos"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="1rpc", chain="cronos"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="1rpc", chain="cronos"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="1rpc", chain="cronos"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="1rpc", chain="cronos"}) / sum(ocb:rpc_call:rate_24h{provider="1rpc", chain="cronos"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="1rpc", chain="cronos"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="1rpc", chain="cronos"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="cronos", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="1rpc", chain="cronos", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="cronos", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="1rpc", chain="cronos", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="cronos", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="1rpc", chain="cronos", region="sgp"}[1h]) + + - slug: cronos-official + name: Cronos + tag: Cronos Labs public RPC, Cronos EVM only + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Cronos's no-key Cronos endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="cronos-official", chain="cronos"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="cronos-official", chain="cronos"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="cronos-official", chain="cronos"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="cronos-official", chain="cronos"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="cronos-official", chain="cronos"}) / sum(ocb:rpc_call:rate_24h{provider="cronos-official", chain="cronos"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="cronos-official", chain="cronos"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="cronos-official", chain="cronos"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="cronos-official", chain="cronos", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="cronos-official", chain="cronos", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="cronos-official", chain="cronos", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="cronos-official", chain="cronos", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="cronos-official", chain="cronos", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="cronos-official", chain="cronos", region="sgp"}[1h]) + diff --git a/benchmarks/fraxtal-rpc.yml b/benchmarks/fraxtal-rpc.yml new file mode 100644 index 00000000..bfaaec28 --- /dev/null +++ b/benchmarks/fraxtal-rpc.yml @@ -0,0 +1,159 @@ +# OpenChainBench. Bench № 065 + +slug: fraxtal-rpc +number: "065" +title: Fastest free Fraxtal RPC, live no-key endpoint latency +seo_title: "Fastest free Fraxtal RPC 2026" +seo_description: "{{best_name}} leads free Fraxtal RPC at {{best_p50}} (eth_blockNumber p50, 24h). 4 no-key providers measured every 15s from 3 regions." +subtitle: HTTP round-trip latency for eth_blockNumber against every free, no-key public Fraxtal RPC endpoint, audited every 15 seconds from 3 regions. + +category: RPCs +status: live +metric: RPC latency +unit: ms +higher_is_better: false + +seo_intro: | + Fraxtal, Frax's OP Stack rollup, fields 4 no-key providers: PublicNode, dRPC, Tenderly and Frax's own `rpc.frax.com`. The four-provider floor it just clears is the cluster's deliberate admission bar, chains that could not field four keyless endpoints (Mode, Zora, Abstract) were left out of the expansion entirely rather than shipped as two-row leaderboards. Identical probes every 15 seconds, three regions. + +abstract: | + Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_blockNumber`) against every no-key public Fraxtal endpoint that sustains continuous probing, 4 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, so the leaderboard rewards sustained, honest availability rather than a fast error message. The cross-chain view lives on the parent rpc-capabilities benchmark; this page is the Fraxtal-scoped answer with per-region breakdowns as a first-class dimension. + +methodology: + - "Cadence: every 15 seconds per provider, from each of 3 probe regions (us-east Virginia, eu-west Amsterdam, sgp Singapore). Headline p50/p90/p99 aggregate across all 3 regions via Prometheus `avg(quantile_over_time(...))`; per-region breakdowns are first-class on this page via the region tabs." + - "Payload: `{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_blockNumber\",\"params\":[]}`. Plain HTTP POST, identical for every endpoint, no API key in any request." + - "Latency: client-side round-trip delta in milliseconds, exposed as both a gauge and a histogram (buckets 50 ms → 10 s), so percentiles are computed via Prometheus `quantile_over_time` over the last 24 hours." + - "Call-result classification: `ok` (HTTP 200 + non-empty result), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 20 blocks behind the cross-provider tip), `timeout`. Latency without reliability is a misleading ranking signal." + - "Archive depth: every 5 minutes we issue `eth_getBalance` at (head − depth) for depths from Geth's default pruned cap up to 5M blocks, exposing which free endpoints actually serve historical state." + - "This page is part of the per-chain RPC cluster derived from the cross-chain [rpc-capabilities](https://openchainbench.com/benchmarks/rpc-capabilities) benchmark; the identical harness, methodology and exclusion rules apply on every chain." + - "Chain scope: every query on this page is pinned to chain=\"fraxtal\". Provider coverage: 4 no-key endpoints (PublicNode, dRPC, Tenderly, Fraxtal). Exclusions follow the cluster-wide rules documented on the parent benchmark." + +findings: + - "{{best_name}} currently leads free Fraxtal RPC at {{best_p50}} (`eth_blockNumber` p50, 24h) across 4 measured providers." + - "{{name:drpc}} ({{p50:drpc}}) closes out its expansion pattern here, anycast consistency across us-east, eu-west and Singapore, the profile that wins it 10 of the 12 new chains on the 3-region average." + - "{{name:tenderly}} posts the recurring long-tail flat line, ~330 ms from every origin at once, single-origin routing on a gateway whose edge network clearly does not front Fraxtal." + - "Fraxtal sits exactly at the cluster's admission bar of 4 keyless providers, the fact that Mode, Zora and Abstract missed; a thin field makes the success-rate column the tiebreaker the median cannot show." + +faq: + - q: "What is the fastest free Fraxtal RPC right now?" + a: "{{best_name}} currently leads at {{best_p50}} (`eth_blockNumber` p50 over the last 24h), measured against 4 no-key providers probed every 15 seconds from us-east, eu-west and Singapore. The leaderboard re-sorts continuously against fresh Prometheus samples, so the answer on this page is the answer right now, not a quarterly snapshot. Use the region tabs to see the leader from the origin closest to your deployment." + - q: "Which Fraxtal RPCs work without an API key?" + a: "The 4 providers on this page: PublicNode, dRPC, Tenderly, Fraxtal. Every (provider, chain) pair was live-verified no-key before inclusion, and anything that key-gates, region-blocks or rate-limits below our 15-second cadence is excluded rather than listed with an asterisk." + - q: "Does the fastest Fraxtal RPC change by region?" + a: "Frequently. The headline number averages three probe origins (us-east, eu-west, Singapore), but per-region leaders regularly diverge, a gateway that wins from Virginia can lose from Singapore by multiples. The region tabs at the top of the page re-scope every number on the page to a single origin; pick the one closest to where your requests actually originate." + - q: "How is Fraxtal RPC latency measured here?" + a: "One identical JSON-RPC POST (`eth_blockNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. Wall-clock round-trip is recorded at millisecond precision; p50/p90/p99 are computed via Prometheus `quantile_over_time` over 24 hours. Responses are classified (`ok` / `http_err` / `jsonrpc_err` / `stale` / `timeout`) so an endpoint stuck on an old head or returning errors behind HTTP 200 is never ranked as fastest. The harness is open source and every number on this page is a public Prometheus query you can run yourself." + - q: "Why are chains like Mode, Zora or Sei missing from this cluster?" + a: "Each failed a specific admission test. Mode, Zora and Abstract could not field 4 keyless providers, below the bar for a meaningful leaderboard. Sei was excluded because dRPC caches `eth_blockNumber` there, poisoning the exact probe we rank on. opBNB fell out because 1RPC returns 429 at our 15-second cadence, leaving only 3 solid providers. The cluster only ships chains where the comparison is honest." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/rpc-capabilities + +prometheus: + window: 24h + freshness_metric: rpc_latency_milliseconds + +# Per-cell (region) ranking matrix for scoped badge claims. Chain is +# fixed for the whole bench, so cells key on region alone. +rank_matrix_query: avg by (provider, region) (ocb:rpc_latency_milliseconds:p50_24h{chain="fraxtal"}) + +# Region is the only dimension: chain is baked into every query. +dimensions: + region: + - { value: all, label: All regions } + - { value: us-east, label: US-East } + - { value: eu-west, label: EU-West } + - { value: sgp, label: Singapore } + +providers: + - slug: publicnode + name: PublicNode + tag: Allnodes-operated, 70+ chains, archive on most + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Fraxtal endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="fraxtal"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="publicnode", chain="fraxtal"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="publicnode", chain="fraxtal"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="publicnode", chain="fraxtal"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="publicnode", chain="fraxtal"}) / sum(ocb:rpc_call:rate_24h{provider="publicnode", chain="fraxtal"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="publicnode", chain="fraxtal"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="fraxtal"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="fraxtal", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="fraxtal", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="fraxtal", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="fraxtal", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="fraxtal", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="fraxtal", region="sgp"}[1h]) + + - slug: drpc + name: dRPC + tag: Decentralized RPC mesh, consensus-checked + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Fraxtal endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="fraxtal"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="fraxtal"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="drpc", chain="fraxtal"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="drpc", chain="fraxtal"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="drpc", chain="fraxtal"}) / sum(ocb:rpc_call:rate_24h{provider="drpc", chain="fraxtal"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="drpc", chain="fraxtal"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="fraxtal"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="fraxtal", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="fraxtal", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="fraxtal", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="fraxtal", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="fraxtal", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="fraxtal", region="sgp"}[1h]) + + - slug: tenderly + name: Tenderly + tag: Multi-chain public gateway, no key + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key Fraxtal endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="fraxtal"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="tenderly", chain="fraxtal"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="tenderly", chain="fraxtal"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="tenderly", chain="fraxtal"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="tenderly", chain="fraxtal"}) / sum(ocb:rpc_call:rate_24h{provider="tenderly", chain="fraxtal"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="tenderly", chain="fraxtal"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="fraxtal"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="fraxtal", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="fraxtal", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="fraxtal", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="fraxtal", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="fraxtal", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="fraxtal", region="sgp"}[1h]) + + - slug: fraxtal-official + name: Fraxtal + tag: Frax-operated public RPC, Fraxtal mainnet only + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Fraxtal's no-key Fraxtal endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="fraxtal-official", chain="fraxtal"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="fraxtal-official", chain="fraxtal"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="fraxtal-official", chain="fraxtal"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="fraxtal-official", chain="fraxtal"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="fraxtal-official", chain="fraxtal"}) / sum(ocb:rpc_call:rate_24h{provider="fraxtal-official", chain="fraxtal"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="fraxtal-official", chain="fraxtal"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="fraxtal-official", chain="fraxtal"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="fraxtal-official", chain="fraxtal", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="fraxtal-official", chain="fraxtal", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="fraxtal-official", chain="fraxtal", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="fraxtal-official", chain="fraxtal", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="fraxtal-official", chain="fraxtal", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="fraxtal-official", chain="fraxtal", region="sgp"}[1h]) + diff --git a/benchmarks/gnosis-rpc.yml b/benchmarks/gnosis-rpc.yml new file mode 100644 index 00000000..8b1902ad --- /dev/null +++ b/benchmarks/gnosis-rpc.yml @@ -0,0 +1,205 @@ +# OpenChainBench. Bench № 056 + +slug: gnosis-rpc +number: "056" +title: Fastest free Gnosis RPC, live no-key endpoint latency +seo_title: "Fastest free Gnosis RPC 2026" +seo_description: "{{best_name}} leads free Gnosis RPC at {{best_p50}} (eth_blockNumber p50, 24h). 6 no-key providers measured every 15s from 3 regions." +subtitle: HTTP round-trip latency for eth_blockNumber against every free, no-key public Gnosis RPC endpoint, audited every 15 seconds from 3 regions. + +category: RPCs +status: live +metric: RPC latency +unit: ms +higher_is_better: false + +seo_intro: | + Gnosis is the cluster's clearest proof that official does not mean fast: the chain-official `rpc.gnosischain.com` is the slowest endpoint we measure on the chain, around 433 ms p50 on the 3-region average, while five third-party gateways beat it, including Nodies, whose POKT-backed infrastructure reaches Gnosis as its only chain in this 12-chain expansion. 6 no-key providers, the same `eth_blockNumber` call every 15 seconds, three regions. + +abstract: | + Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_blockNumber`) against every no-key public Gnosis endpoint that sustains continuous probing, 6 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, so the leaderboard rewards sustained, honest availability rather than a fast error message. The cross-chain view lives on the parent rpc-capabilities benchmark; this page is the Gnosis-scoped answer with per-region breakdowns as a first-class dimension. + +methodology: + - "Cadence: every 15 seconds per provider, from each of 3 probe regions (us-east Virginia, eu-west Amsterdam, sgp Singapore). Headline p50/p90/p99 aggregate across all 3 regions via Prometheus `avg(quantile_over_time(...))`; per-region breakdowns are first-class on this page via the region tabs." + - "Payload: `{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_blockNumber\",\"params\":[]}`. Plain HTTP POST, identical for every endpoint, no API key in any request." + - "Latency: client-side round-trip delta in milliseconds, exposed as both a gauge and a histogram (buckets 50 ms → 10 s), so percentiles are computed via Prometheus `quantile_over_time` over the last 24 hours." + - "Call-result classification: `ok` (HTTP 200 + non-empty result), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 20 blocks behind the cross-provider tip), `timeout`. Latency without reliability is a misleading ranking signal." + - "Archive depth: every 5 minutes we issue `eth_getBalance` at (head − depth) for depths from Geth's default pruned cap up to 5M blocks, exposing which free endpoints actually serve historical state." + - "This page is part of the per-chain RPC cluster derived from the cross-chain [rpc-capabilities](https://openchainbench.com/benchmarks/rpc-capabilities) benchmark; the identical harness, methodology and exclusion rules apply on every chain." + - "Chain scope: every query on this page is pinned to chain=\"gnosis\". Provider coverage: 6 no-key endpoints (PublicNode, dRPC, 1RPC, Tenderly, Nodies, Gnosis). Exclusions follow the cluster-wide rules documented on the parent benchmark." + +findings: + - "{{best_name}} currently leads free Gnosis RPC at {{best_p50}} (`eth_blockNumber` p50, 24h) across 6 measured providers." + - "The chain-official endpoint anchors the wrong end of the board: ~433 ms p50 with a similar profile from every region, slower than every gateway on this page. It is honest about its blocks; it is just slow." + - "{{name:nodies}} ({{p50:nodies}}) is the quiet story of the expansion: Gnosis is the only long-tail chain it qualifies on, and it serves the chain well, a POKT-routed gateway beating the house endpoint by a wide margin." + - "{{name:drpc}} ({{p50:drpc}}) shows its usual anycast consistency here, part of the pattern that has it leading 10 of the 12 long-tail chains on the 3-region average." + +faq: + - q: "What is the fastest free Gnosis RPC right now?" + a: "{{best_name}} currently leads at {{best_p50}} (`eth_blockNumber` p50 over the last 24h), measured against 6 no-key providers probed every 15 seconds from us-east, eu-west and Singapore. The leaderboard re-sorts continuously against fresh Prometheus samples, so the answer on this page is the answer right now, not a quarterly snapshot. Use the region tabs to see the leader from the origin closest to your deployment." + - q: "Which Gnosis RPCs work without an API key?" + a: "The 6 providers on this page: PublicNode, dRPC, 1RPC, Tenderly, Nodies, Gnosis. Every (provider, chain) pair was live-verified no-key before inclusion, and anything that key-gates, region-blocks or rate-limits below our 15-second cadence is excluded rather than listed with an asterisk." + - q: "Does the fastest Gnosis RPC change by region?" + a: "Frequently. The headline number averages three probe origins (us-east, eu-west, Singapore), but per-region leaders regularly diverge, a gateway that wins from Virginia can lose from Singapore by multiples. The region tabs at the top of the page re-scope every number on the page to a single origin; pick the one closest to where your requests actually originate." + - q: "How is Gnosis RPC latency measured here?" + a: "One identical JSON-RPC POST (`eth_blockNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. Wall-clock round-trip is recorded at millisecond precision; p50/p90/p99 are computed via Prometheus `quantile_over_time` over 24 hours. Responses are classified (`ok` / `http_err` / `jsonrpc_err` / `stale` / `timeout`) so an endpoint stuck on an old head or returning errors behind HTTP 200 is never ranked as fastest. The harness is open source and every number on this page is a public Prometheus query you can run yourself." + - q: "Should I use rpc.gnosischain.com as my Gnosis RPC?" + a: "Only as a fallback. It is the slowest endpoint we measure on Gnosis, roughly 433 ms median across three regions, several times the gateway tier, though its reliability and head freshness are fine. The measured leaders above serve the same chain with a fraction of the round trip; keep the official endpoint in the rotation for redundancy rather than as primary." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/rpc-capabilities + +prometheus: + window: 24h + freshness_metric: rpc_latency_milliseconds + +# Per-cell (region) ranking matrix for scoped badge claims. Chain is +# fixed for the whole bench, so cells key on region alone. +rank_matrix_query: avg by (provider, region) (ocb:rpc_latency_milliseconds:p50_24h{chain="gnosis"}) + +# Region is the only dimension: chain is baked into every query. +dimensions: + region: + - { value: all, label: All regions } + - { value: us-east, label: US-East } + - { value: eu-west, label: EU-West } + - { value: sgp, label: Singapore } + +providers: + - slug: publicnode + name: PublicNode + tag: Allnodes-operated, 70+ chains, archive on most + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Gnosis endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="gnosis"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="publicnode", chain="gnosis"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="publicnode", chain="gnosis"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="publicnode", chain="gnosis"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="publicnode", chain="gnosis"}) / sum(ocb:rpc_call:rate_24h{provider="publicnode", chain="gnosis"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="publicnode", chain="gnosis"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="gnosis"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="gnosis", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="gnosis", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="gnosis", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="gnosis", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="gnosis", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="gnosis", region="sgp"}[1h]) + + - slug: drpc + name: dRPC + tag: Decentralized RPC mesh, consensus-checked + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Gnosis endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="gnosis"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="gnosis"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="drpc", chain="gnosis"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="drpc", chain="gnosis"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="drpc", chain="gnosis"}) / sum(ocb:rpc_call:rate_24h{provider="drpc", chain="gnosis"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="drpc", chain="gnosis"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="gnosis"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="gnosis", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="gnosis", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="gnosis", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="gnosis", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="gnosis", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="gnosis", region="sgp"}[1h]) + + - slug: 1rpc + name: 1RPC + tag: Privacy-preserving gateway by Automata Network + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to 1RPC's no-key Gnosis endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="gnosis"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="1rpc", chain="gnosis"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="1rpc", chain="gnosis"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="1rpc", chain="gnosis"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="1rpc", chain="gnosis"}) / sum(ocb:rpc_call:rate_24h{provider="1rpc", chain="gnosis"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="1rpc", chain="gnosis"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="1rpc", chain="gnosis"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="gnosis", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="1rpc", chain="gnosis", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="gnosis", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="1rpc", chain="gnosis", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="gnosis", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="1rpc", chain="gnosis", region="sgp"}[1h]) + + - slug: tenderly + name: Tenderly + tag: Multi-chain public gateway, no key + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key Gnosis endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="gnosis"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="tenderly", chain="gnosis"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="tenderly", chain="gnosis"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="tenderly", chain="gnosis"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="tenderly", chain="gnosis"}) / sum(ocb:rpc_call:rate_24h{provider="tenderly", chain="gnosis"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="tenderly", chain="gnosis"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="gnosis"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="gnosis", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="gnosis", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="gnosis", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="gnosis", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="gnosis", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="gnosis", region="sgp"}[1h]) + + - slug: nodies + name: Nodies + tag: POKT Network's decentralized public RPC successor, 7+ chains + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Nodies's no-key Gnosis endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nodies", chain="gnosis"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="nodies", chain="gnosis"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="nodies", chain="gnosis"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="nodies", chain="gnosis"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="nodies", chain="gnosis"}) / sum(ocb:rpc_call:rate_24h{provider="nodies", chain="gnosis"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="nodies", chain="gnosis"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="nodies", chain="gnosis"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nodies", chain="gnosis", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="nodies", chain="gnosis", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nodies", chain="gnosis", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="nodies", chain="gnosis", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="nodies", chain="gnosis", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="nodies", chain="gnosis", region="sgp"}[1h]) + + - slug: gnosis-official + name: Gnosis + tag: Gnosis chain-official RPC (rpc.gnosischain.com) + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Gnosis's no-key Gnosis endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="gnosis-official", chain="gnosis"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="gnosis-official", chain="gnosis"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="gnosis-official", chain="gnosis"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="gnosis-official", chain="gnosis"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="gnosis-official", chain="gnosis"}) / sum(ocb:rpc_call:rate_24h{provider="gnosis-official", chain="gnosis"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="gnosis-official", chain="gnosis"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="gnosis-official", chain="gnosis"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="gnosis-official", chain="gnosis", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="gnosis-official", chain="gnosis", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="gnosis-official", chain="gnosis", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="gnosis-official", chain="gnosis", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="gnosis-official", chain="gnosis", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="gnosis-official", chain="gnosis", region="sgp"}[1h]) + diff --git a/benchmarks/moonbeam-rpc.yml b/benchmarks/moonbeam-rpc.yml new file mode 100644 index 00000000..179d4266 --- /dev/null +++ b/benchmarks/moonbeam-rpc.yml @@ -0,0 +1,182 @@ +# OpenChainBench. Bench № 058 + +slug: moonbeam-rpc +number: "058" +title: Fastest free Moonbeam RPC, live no-key endpoint latency +seo_title: "Fastest free Moonbeam RPC 2026" +seo_description: "{{best_name}} leads free Moonbeam RPC at {{best_p50}} (eth_blockNumber p50, 24h). 5 no-key providers measured every 15s from 3 regions." +subtitle: HTTP round-trip latency for eth_blockNumber against every free, no-key public Moonbeam RPC endpoint, audited every 15 seconds from 3 regions. + +category: RPCs +status: live +metric: RPC latency +unit: ms +higher_is_better: false + +seo_intro: | + Moonbeam, Polkadot's EVM parachain, fields 5 no-key providers including the Moonbeam Foundation's `rpc.api.moonbeam.network`. One integration trap surfaced in our verification sweep: 1RPC addresses the chain by its token code, so the working path is `1rpc.io/glmr` and the intuitive `/moonbeam` returns HTTP 400. Probes run every 15 seconds from three regions with full response classification. + +abstract: | + Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_blockNumber`) against every no-key public Moonbeam endpoint that sustains continuous probing, 5 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, so the leaderboard rewards sustained, honest availability rather than a fast error message. The cross-chain view lives on the parent rpc-capabilities benchmark; this page is the Moonbeam-scoped answer with per-region breakdowns as a first-class dimension. + +methodology: + - "Cadence: every 15 seconds per provider, from each of 3 probe regions (us-east Virginia, eu-west Amsterdam, sgp Singapore). Headline p50/p90/p99 aggregate across all 3 regions via Prometheus `avg(quantile_over_time(...))`; per-region breakdowns are first-class on this page via the region tabs." + - "Payload: `{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_blockNumber\",\"params\":[]}`. Plain HTTP POST, identical for every endpoint, no API key in any request." + - "Latency: client-side round-trip delta in milliseconds, exposed as both a gauge and a histogram (buckets 50 ms → 10 s), so percentiles are computed via Prometheus `quantile_over_time` over the last 24 hours." + - "Call-result classification: `ok` (HTTP 200 + non-empty result), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 20 blocks behind the cross-provider tip), `timeout`. Latency without reliability is a misleading ranking signal." + - "Archive depth: every 5 minutes we issue `eth_getBalance` at (head − depth) for depths from Geth's default pruned cap up to 5M blocks, exposing which free endpoints actually serve historical state." + - "This page is part of the per-chain RPC cluster derived from the cross-chain [rpc-capabilities](https://openchainbench.com/benchmarks/rpc-capabilities) benchmark; the identical harness, methodology and exclusion rules apply on every chain." + - "Chain scope: every query on this page is pinned to chain=\"moonbeam\". Provider coverage: 5 no-key endpoints (PublicNode, dRPC, 1RPC, Tenderly, Moonbeam). Exclusions follow the cluster-wide rules documented on the parent benchmark." + +findings: + - "{{best_name}} currently leads free Moonbeam RPC at {{best_p50}} (`eth_blockNumber` p50, 24h) across 5 measured providers." + - "{{name:drpc}} ({{p50:drpc}}) carries its anycast-consistency pattern onto a Polkadot parachain unchanged: same probe, same edge behavior, same 3-region steadiness that wins it 10 of the 12 long-tail chains." + - "{{name:1rpc}} is reachable only at the token-code path `1rpc.io/glmr`; the obvious `/moonbeam` URL 400s, the kind of detail no status page documents and this bench exists to encode." + - "{{name:tenderly}} posts the expansion's recurring flat ~330 ms in every region on Moonbeam too, single-origin routing rather than the edge network it runs for the major chains." + +faq: + - q: "What is the fastest free Moonbeam RPC right now?" + a: "{{best_name}} currently leads at {{best_p50}} (`eth_blockNumber` p50 over the last 24h), measured against 5 no-key providers probed every 15 seconds from us-east, eu-west and Singapore. The leaderboard re-sorts continuously against fresh Prometheus samples, so the answer on this page is the answer right now, not a quarterly snapshot. Use the region tabs to see the leader from the origin closest to your deployment." + - q: "Which Moonbeam RPCs work without an API key?" + a: "The 5 providers on this page: PublicNode, dRPC, 1RPC, Tenderly, Moonbeam. Every (provider, chain) pair was live-verified no-key before inclusion, and anything that key-gates, region-blocks or rate-limits below our 15-second cadence is excluded rather than listed with an asterisk." + - q: "Does the fastest Moonbeam RPC change by region?" + a: "Frequently. The headline number averages three probe origins (us-east, eu-west, Singapore), but per-region leaders regularly diverge, a gateway that wins from Virginia can lose from Singapore by multiples. The region tabs at the top of the page re-scope every number on the page to a single origin; pick the one closest to where your requests actually originate." + - q: "How is Moonbeam RPC latency measured here?" + a: "One identical JSON-RPC POST (`eth_blockNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. Wall-clock round-trip is recorded at millisecond precision; p50/p90/p99 are computed via Prometheus `quantile_over_time` over 24 hours. Responses are classified (`ok` / `http_err` / `jsonrpc_err` / `stale` / `timeout`) so an endpoint stuck on an old head or returning errors behind HTTP 200 is never ranked as fastest. The harness is open source and every number on this page is a public Prometheus query you can run yourself." + - q: "Why does 1RPC's Moonbeam endpoint use /glmr instead of /moonbeam?" + a: "1RPC keys several chain paths on native-token tickers, and GLMR is Moonbeam's token, so the working endpoint is `1rpc.io/glmr` while `1rpc.io/moonbeam` returns HTTP 400. Our harness verified the chain identity behind the path (eth_chainId 1284) before admitting it, so the numbers above are guaranteed to be Moonbeam mainnet and not a lookalike." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/rpc-capabilities + +prometheus: + window: 24h + freshness_metric: rpc_latency_milliseconds + +# Per-cell (region) ranking matrix for scoped badge claims. Chain is +# fixed for the whole bench, so cells key on region alone. +rank_matrix_query: avg by (provider, region) (ocb:rpc_latency_milliseconds:p50_24h{chain="moonbeam"}) + +# Region is the only dimension: chain is baked into every query. +dimensions: + region: + - { value: all, label: All regions } + - { value: us-east, label: US-East } + - { value: eu-west, label: EU-West } + - { value: sgp, label: Singapore } + +providers: + - slug: publicnode + name: PublicNode + tag: Allnodes-operated, 70+ chains, archive on most + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Moonbeam endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="moonbeam"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="publicnode", chain="moonbeam"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="publicnode", chain="moonbeam"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="publicnode", chain="moonbeam"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="publicnode", chain="moonbeam"}) / sum(ocb:rpc_call:rate_24h{provider="publicnode", chain="moonbeam"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="publicnode", chain="moonbeam"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="moonbeam"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="moonbeam", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="moonbeam", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="moonbeam", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="moonbeam", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="moonbeam", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="moonbeam", region="sgp"}[1h]) + + - slug: drpc + name: dRPC + tag: Decentralized RPC mesh, consensus-checked + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Moonbeam endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="moonbeam"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="moonbeam"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="drpc", chain="moonbeam"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="drpc", chain="moonbeam"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="drpc", chain="moonbeam"}) / sum(ocb:rpc_call:rate_24h{provider="drpc", chain="moonbeam"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="drpc", chain="moonbeam"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="moonbeam"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="moonbeam", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="moonbeam", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="moonbeam", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="moonbeam", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="moonbeam", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="moonbeam", region="sgp"}[1h]) + + - slug: 1rpc + name: 1RPC + tag: Privacy-preserving gateway by Automata Network + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to 1RPC's no-key Moonbeam endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="moonbeam"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="1rpc", chain="moonbeam"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="1rpc", chain="moonbeam"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="1rpc", chain="moonbeam"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="1rpc", chain="moonbeam"}) / sum(ocb:rpc_call:rate_24h{provider="1rpc", chain="moonbeam"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="1rpc", chain="moonbeam"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="1rpc", chain="moonbeam"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="moonbeam", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="1rpc", chain="moonbeam", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="moonbeam", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="1rpc", chain="moonbeam", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="moonbeam", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="1rpc", chain="moonbeam", region="sgp"}[1h]) + + - slug: tenderly + name: Tenderly + tag: Multi-chain public gateway, no key + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key Moonbeam endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="moonbeam"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="tenderly", chain="moonbeam"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="tenderly", chain="moonbeam"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="tenderly", chain="moonbeam"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="tenderly", chain="moonbeam"}) / sum(ocb:rpc_call:rate_24h{provider="tenderly", chain="moonbeam"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="tenderly", chain="moonbeam"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="moonbeam"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="moonbeam", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="moonbeam", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="moonbeam", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="moonbeam", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="moonbeam", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="moonbeam", region="sgp"}[1h]) + + - slug: moonbeam-official + name: Moonbeam + tag: Moonbeam Foundation public RPC, Moonbeam only + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Moonbeam's no-key Moonbeam endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="moonbeam-official", chain="moonbeam"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="moonbeam-official", chain="moonbeam"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="moonbeam-official", chain="moonbeam"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="moonbeam-official", chain="moonbeam"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="moonbeam-official", chain="moonbeam"}) / sum(ocb:rpc_call:rate_24h{provider="moonbeam-official", chain="moonbeam"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="moonbeam-official", chain="moonbeam"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="moonbeam-official", chain="moonbeam"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="moonbeam-official", chain="moonbeam", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="moonbeam-official", chain="moonbeam", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="moonbeam-official", chain="moonbeam", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="moonbeam-official", chain="moonbeam", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="moonbeam-official", chain="moonbeam", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="moonbeam-official", chain="moonbeam", region="sgp"}[1h]) + diff --git a/benchmarks/soneium-rpc.yml b/benchmarks/soneium-rpc.yml new file mode 100644 index 00000000..a9c8b58e --- /dev/null +++ b/benchmarks/soneium-rpc.yml @@ -0,0 +1,159 @@ +# OpenChainBench. Bench № 066 + +slug: soneium-rpc +number: "066" +title: Fastest free Soneium RPC, live no-key endpoint latency +seo_title: "Fastest free Soneium RPC 2026" +seo_description: "{{best_name}} leads free Soneium RPC at {{best_p50}} (eth_blockNumber p50, 24h). 4 no-key providers measured every 15s from 3 regions." +subtitle: HTTP round-trip latency for eth_blockNumber against every free, no-key public Soneium RPC endpoint, audited every 15 seconds from 3 regions. + +category: RPCs +status: live +metric: RPC latency +unit: ms +higher_is_better: false + +seo_intro: | + Soneium, Sony's OP Stack rollup, is the long-tail chain where the official endpoint actually wins: `rpc.soneium.org` leads at roughly 17 ms on the 3-region average, distributed official infrastructure that answers near every probe origin while keeping fresh heads in our stale detection. 4 no-key providers, the identical `eth_blockNumber` call every 15 seconds from us-east, eu-west and Singapore. + +abstract: | + Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_blockNumber`) against every no-key public Soneium endpoint that sustains continuous probing, 4 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, so the leaderboard rewards sustained, honest availability rather than a fast error message. The cross-chain view lives on the parent rpc-capabilities benchmark; this page is the Soneium-scoped answer with per-region breakdowns as a first-class dimension. + +methodology: + - "Cadence: every 15 seconds per provider, from each of 3 probe regions (us-east Virginia, eu-west Amsterdam, sgp Singapore). Headline p50/p90/p99 aggregate across all 3 regions via Prometheus `avg(quantile_over_time(...))`; per-region breakdowns are first-class on this page via the region tabs." + - "Payload: `{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_blockNumber\",\"params\":[]}`. Plain HTTP POST, identical for every endpoint, no API key in any request." + - "Latency: client-side round-trip delta in milliseconds, exposed as both a gauge and a histogram (buckets 50 ms → 10 s), so percentiles are computed via Prometheus `quantile_over_time` over the last 24 hours." + - "Call-result classification: `ok` (HTTP 200 + non-empty result), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 20 blocks behind the cross-provider tip), `timeout`. Latency without reliability is a misleading ranking signal." + - "Archive depth: every 5 minutes we issue `eth_getBalance` at (head − depth) for depths from Geth's default pruned cap up to 5M blocks, exposing which free endpoints actually serve historical state." + - "This page is part of the per-chain RPC cluster derived from the cross-chain [rpc-capabilities](https://openchainbench.com/benchmarks/rpc-capabilities) benchmark; the identical harness, methodology and exclusion rules apply on every chain." + - "Chain scope: every query on this page is pinned to chain=\"soneium\". Provider coverage: 4 no-key endpoints (PublicNode, dRPC, Tenderly, Soneium). Exclusions follow the cluster-wide rules documented on the parent benchmark." + +findings: + - "{{best_name}} currently leads free Soneium RPC at {{best_p50}} (`eth_blockNumber` p50, 24h) across 4 measured providers." + - "The official `rpc.soneium.org` is one of the expansion's two exceptions to the dRPC sweep: ~17 ms on the 3-region average with fresh heads throughout, a chain operator that fronts its RPC properly across regions instead of pointing DNS at one box." + - "{{name:drpc}} ({{p50:drpc}}) still posts its trademark three-region consistency here; it just meets the rare official endpoint built on the same playbook." + - "{{name:tenderly}} closes the pattern the expansion documents everywhere: roughly 330 ms flat in all three regions on Soneium, single-origin routing behind a gateway that is competitive on the majors." + +faq: + - q: "What is the fastest free Soneium RPC right now?" + a: "{{best_name}} currently leads at {{best_p50}} (`eth_blockNumber` p50 over the last 24h), measured against 4 no-key providers probed every 15 seconds from us-east, eu-west and Singapore. The leaderboard re-sorts continuously against fresh Prometheus samples, so the answer on this page is the answer right now, not a quarterly snapshot. Use the region tabs to see the leader from the origin closest to your deployment." + - q: "Which Soneium RPCs work without an API key?" + a: "The 4 providers on this page: PublicNode, dRPC, Tenderly, Soneium. Every (provider, chain) pair was live-verified no-key before inclusion, and anything that key-gates, region-blocks or rate-limits below our 15-second cadence is excluded rather than listed with an asterisk." + - q: "Does the fastest Soneium RPC change by region?" + a: "Frequently. The headline number averages three probe origins (us-east, eu-west, Singapore), but per-region leaders regularly diverge, a gateway that wins from Virginia can lose from Singapore by multiples. The region tabs at the top of the page re-scope every number on the page to a single origin; pick the one closest to where your requests actually originate." + - q: "How is Soneium RPC latency measured here?" + a: "One identical JSON-RPC POST (`eth_blockNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. Wall-clock round-trip is recorded at millisecond precision; p50/p90/p99 are computed via Prometheus `quantile_over_time` over 24 hours. Responses are classified (`ok` / `http_err` / `jsonrpc_err` / `stale` / `timeout`) so an endpoint stuck on an old head or returning errors behind HTTP 200 is never ranked as fastest. The harness is open source and every number on this page is a public Prometheus query you can run yourself." + - q: "Is the official Soneium RPC actually the best choice?" + a: "Currently yes, and that is unusual: across the 12-chain long-tail expansion only two chains resist the gateway tier, and `rpc.soneium.org` is the clearest case, leading the 3-region average at around 17 ms while our stale-head checks stay clean. The usual free-tier caveats (no SLA, shared limits) still apply, so keep {{name:drpc}} or {{name:publicnode}} wired as fallback." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/rpc-capabilities + +prometheus: + window: 24h + freshness_metric: rpc_latency_milliseconds + +# Per-cell (region) ranking matrix for scoped badge claims. Chain is +# fixed for the whole bench, so cells key on region alone. +rank_matrix_query: avg by (provider, region) (ocb:rpc_latency_milliseconds:p50_24h{chain="soneium"}) + +# Region is the only dimension: chain is baked into every query. +dimensions: + region: + - { value: all, label: All regions } + - { value: us-east, label: US-East } + - { value: eu-west, label: EU-West } + - { value: sgp, label: Singapore } + +providers: + - slug: publicnode + name: PublicNode + tag: Allnodes-operated, 70+ chains, archive on most + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Soneium endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="soneium"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="publicnode", chain="soneium"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="publicnode", chain="soneium"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="publicnode", chain="soneium"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="publicnode", chain="soneium"}) / sum(ocb:rpc_call:rate_24h{provider="publicnode", chain="soneium"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="publicnode", chain="soneium"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="soneium"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="soneium", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="soneium", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="soneium", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="soneium", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="soneium", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="soneium", region="sgp"}[1h]) + + - slug: drpc + name: dRPC + tag: Decentralized RPC mesh, consensus-checked + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Soneium endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="soneium"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="soneium"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="drpc", chain="soneium"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="drpc", chain="soneium"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="drpc", chain="soneium"}) / sum(ocb:rpc_call:rate_24h{provider="drpc", chain="soneium"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="drpc", chain="soneium"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="soneium"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="soneium", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="soneium", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="soneium", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="soneium", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="soneium", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="soneium", region="sgp"}[1h]) + + - slug: tenderly + name: Tenderly + tag: Multi-chain public gateway, no key + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key Soneium endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="soneium"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="tenderly", chain="soneium"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="tenderly", chain="soneium"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="tenderly", chain="soneium"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="tenderly", chain="soneium"}) / sum(ocb:rpc_call:rate_24h{provider="tenderly", chain="soneium"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="tenderly", chain="soneium"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="soneium"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="soneium", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="soneium", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="soneium", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="soneium", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="soneium", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="soneium", region="sgp"}[1h]) + + - slug: soneium-official + name: Soneium + tag: Sony Block Solutions public RPC, Soneium mainnet only + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Soneium's no-key Soneium endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="soneium-official", chain="soneium"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="soneium-official", chain="soneium"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="soneium-official", chain="soneium"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="soneium-official", chain="soneium"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="soneium-official", chain="soneium"}) / sum(ocb:rpc_call:rate_24h{provider="soneium-official", chain="soneium"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="soneium-official", chain="soneium"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="soneium-official", chain="soneium"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="soneium-official", chain="soneium", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="soneium-official", chain="soneium", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="soneium-official", chain="soneium", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="soneium-official", chain="soneium", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="soneium-official", chain="soneium", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="soneium-official", chain="soneium", region="sgp"}[1h]) + diff --git a/benchmarks/sonic-rpc.yml b/benchmarks/sonic-rpc.yml new file mode 100644 index 00000000..2c0608d4 --- /dev/null +++ b/benchmarks/sonic-rpc.yml @@ -0,0 +1,205 @@ +# OpenChainBench. Bench № 055 + +slug: sonic-rpc +number: "055" +title: Fastest free Sonic RPC, live no-key endpoint latency +seo_title: "Fastest free Sonic RPC 2026" +seo_description: "{{best_name}} leads free Sonic RPC at {{best_p50}} (eth_blockNumber p50, 24h). 6 no-key providers measured every 15s from 3 regions." +subtitle: HTTP round-trip latency for eth_blockNumber against every free, no-key public Sonic RPC endpoint, audited every 15 seconds from 3 regions. + +category: RPCs +status: live +metric: RPC latency +unit: ms +higher_is_better: false + +seo_intro: | + Sonic ties Gnosis for the largest cohort in the long-tail expansion: 6 no-key providers, including Sonic Labs' own `rpc.soniclabs.com` and the only keyless Lava endpoint outside Ethereum and Arbitrum, `sonic.lava.build` answers no-key while every other Lava subdomain 403s without an API key. Every provider gets the identical `eth_blockNumber` probe every 15 seconds from us-east, eu-west and Singapore, with stale-head detection against the cross-provider tip. + +abstract: | + Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_blockNumber`) against every no-key public Sonic endpoint that sustains continuous probing, 6 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, so the leaderboard rewards sustained, honest availability rather than a fast error message. The cross-chain view lives on the parent rpc-capabilities benchmark; this page is the Sonic-scoped answer with per-region breakdowns as a first-class dimension. + +methodology: + - "Cadence: every 15 seconds per provider, from each of 3 probe regions (us-east Virginia, eu-west Amsterdam, sgp Singapore). Headline p50/p90/p99 aggregate across all 3 regions via Prometheus `avg(quantile_over_time(...))`; per-region breakdowns are first-class on this page via the region tabs." + - "Payload: `{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_blockNumber\",\"params\":[]}`. Plain HTTP POST, identical for every endpoint, no API key in any request." + - "Latency: client-side round-trip delta in milliseconds, exposed as both a gauge and a histogram (buckets 50 ms → 10 s), so percentiles are computed via Prometheus `quantile_over_time` over the last 24 hours." + - "Call-result classification: `ok` (HTTP 200 + non-empty result), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 20 blocks behind the cross-provider tip), `timeout`. Latency without reliability is a misleading ranking signal." + - "Archive depth: every 5 minutes we issue `eth_getBalance` at (head − depth) for depths from Geth's default pruned cap up to 5M blocks, exposing which free endpoints actually serve historical state." + - "This page is part of the per-chain RPC cluster derived from the cross-chain [rpc-capabilities](https://openchainbench.com/benchmarks/rpc-capabilities) benchmark; the identical harness, methodology and exclusion rules apply on every chain." + - "Chain scope: every query on this page is pinned to chain=\"sonic\". Provider coverage: 6 no-key endpoints (PublicNode, dRPC, 1RPC, Tenderly, Lava, Sonic Labs). Exclusions follow the cluster-wide rules documented on the parent benchmark." + +findings: + - "{{best_name}} currently leads free Sonic RPC at {{best_p50}} (`eth_blockNumber` p50, 24h) across 6 measured providers." + - "{{name:drpc}} ({{p50:drpc}}) illustrates the long-tail pattern: it wins 10 of the 12 chains in this expansion on the 3-region average, not by posting the fastest single-region peak but by answering every origin from a nearby anycast edge." + - "{{name:tenderly}} tells the opposite story: roughly 330 ms in every region, the flat signature of single-origin routing. The same gateway is competitive on Ethereum and Base, so this is a per-chain routing decision, not a capacity problem." + - "{{name:lava}} makes Sonic a curiosity: `sonic.lava.build` is the only Lava subdomain that answers no-key outside eth1/arb1, so this page is the cluster's only long-tail read on the Lava mesh." + +faq: + - q: "What is the fastest free Sonic RPC right now?" + a: "{{best_name}} currently leads at {{best_p50}} (`eth_blockNumber` p50 over the last 24h), measured against 6 no-key providers probed every 15 seconds from us-east, eu-west and Singapore. The leaderboard re-sorts continuously against fresh Prometheus samples, so the answer on this page is the answer right now, not a quarterly snapshot. Use the region tabs to see the leader from the origin closest to your deployment." + - q: "Which Sonic RPCs work without an API key?" + a: "The 6 providers on this page: PublicNode, dRPC, 1RPC, Tenderly, Lava, Sonic Labs. Every (provider, chain) pair was live-verified no-key before inclusion, and anything that key-gates, region-blocks or rate-limits below our 15-second cadence is excluded rather than listed with an asterisk." + - q: "Does the fastest Sonic RPC change by region?" + a: "Frequently. The headline number averages three probe origins (us-east, eu-west, Singapore), but per-region leaders regularly diverge, a gateway that wins from Virginia can lose from Singapore by multiples. The region tabs at the top of the page re-scope every number on the page to a single origin; pick the one closest to where your requests actually originate." + - q: "How is Sonic RPC latency measured here?" + a: "One identical JSON-RPC POST (`eth_blockNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. Wall-clock round-trip is recorded at millisecond precision; p50/p90/p99 are computed via Prometheus `quantile_over_time` over 24 hours. Responses are classified (`ok` / `http_err` / `jsonrpc_err` / `stale` / `timeout`) so an endpoint stuck on an old head or returning errors behind HTTP 200 is never ranked as fastest. The harness is open source and every number on this page is a public Prometheus query you can run yourself." + - q: "Why does Lava appear on Sonic but on no other long-tail chain?" + a: "Lava publishes `*.lava.build` subdomains for many chains, but nearly all of them return 403 without an API key. `sonic.lava.build` is the exception: it passed our no-key verification (eth_chainId match plus sustained probing) and has held the 15-second cadence since. Every (provider, chain) pair in this cluster is admitted on measured behavior, not on a provider's published chain list." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/rpc-capabilities + +prometheus: + window: 24h + freshness_metric: rpc_latency_milliseconds + +# Per-cell (region) ranking matrix for scoped badge claims. Chain is +# fixed for the whole bench, so cells key on region alone. +rank_matrix_query: avg by (provider, region) (ocb:rpc_latency_milliseconds:p50_24h{chain="sonic"}) + +# Region is the only dimension: chain is baked into every query. +dimensions: + region: + - { value: all, label: All regions } + - { value: us-east, label: US-East } + - { value: eu-west, label: EU-West } + - { value: sgp, label: Singapore } + +providers: + - slug: publicnode + name: PublicNode + tag: Allnodes-operated, 70+ chains, archive on most + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Sonic endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="sonic"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="publicnode", chain="sonic"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="publicnode", chain="sonic"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="publicnode", chain="sonic"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="publicnode", chain="sonic"}) / sum(ocb:rpc_call:rate_24h{provider="publicnode", chain="sonic"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="publicnode", chain="sonic"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="sonic"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="sonic", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="sonic", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="sonic", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="sonic", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="sonic", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="sonic", region="sgp"}[1h]) + + - slug: drpc + name: dRPC + tag: Decentralized RPC mesh, consensus-checked + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Sonic endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="sonic"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="sonic"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="drpc", chain="sonic"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="drpc", chain="sonic"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="drpc", chain="sonic"}) / sum(ocb:rpc_call:rate_24h{provider="drpc", chain="sonic"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="drpc", chain="sonic"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="sonic"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="sonic", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="sonic", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="sonic", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="sonic", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="sonic", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="sonic", region="sgp"}[1h]) + + - slug: 1rpc + name: 1RPC + tag: Privacy-preserving gateway by Automata Network + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to 1RPC's no-key Sonic endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="sonic"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="1rpc", chain="sonic"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="1rpc", chain="sonic"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="1rpc", chain="sonic"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="1rpc", chain="sonic"}) / sum(ocb:rpc_call:rate_24h{provider="1rpc", chain="sonic"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="1rpc", chain="sonic"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="1rpc", chain="sonic"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="sonic", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="1rpc", chain="sonic", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="sonic", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="1rpc", chain="sonic", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="sonic", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="1rpc", chain="sonic", region="sgp"}[1h]) + + - slug: tenderly + name: Tenderly + tag: Multi-chain public gateway, no key + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key Sonic endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="sonic"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="tenderly", chain="sonic"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="tenderly", chain="sonic"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="tenderly", chain="sonic"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="tenderly", chain="sonic"}) / sum(ocb:rpc_call:rate_24h{provider="tenderly", chain="sonic"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="tenderly", chain="sonic"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="sonic"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="sonic", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="sonic", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="sonic", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="sonic", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="sonic", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="sonic", region="sgp"}[1h]) + + - slug: lava + name: Lava + tag: Decentralized permissionless RPC mesh (sonic.lava.build, open no-key) + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Lava's no-key Sonic endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="lava", chain="sonic"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="lava", chain="sonic"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="lava", chain="sonic"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="lava", chain="sonic"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="lava", chain="sonic"}) / sum(ocb:rpc_call:rate_24h{provider="lava", chain="sonic"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="lava", chain="sonic"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="lava", chain="sonic"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="lava", chain="sonic", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="lava", chain="sonic", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="lava", chain="sonic", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="lava", chain="sonic", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="lava", chain="sonic", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="lava", chain="sonic", region="sgp"}[1h]) + + - slug: sonic-official + name: Sonic Labs + tag: Sonic Labs public RPC, Sonic mainnet only + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Sonic Labs's no-key Sonic endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="sonic-official", chain="sonic"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="sonic-official", chain="sonic"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="sonic-official", chain="sonic"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="sonic-official", chain="sonic"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="sonic-official", chain="sonic"}) / sum(ocb:rpc_call:rate_24h{provider="sonic-official", chain="sonic"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="sonic-official", chain="sonic"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="sonic-official", chain="sonic"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="sonic-official", chain="sonic", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="sonic-official", chain="sonic", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="sonic-official", chain="sonic", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="sonic-official", chain="sonic", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="sonic-official", chain="sonic", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="sonic-official", chain="sonic", region="sgp"}[1h]) + diff --git a/benchmarks/taiko-rpc.yml b/benchmarks/taiko-rpc.yml new file mode 100644 index 00000000..b6fc045d --- /dev/null +++ b/benchmarks/taiko-rpc.yml @@ -0,0 +1,159 @@ +# OpenChainBench. Bench № 061 + +slug: taiko-rpc +number: "061" +title: Fastest free Taiko RPC, live no-key endpoint latency +seo_title: "Fastest free Taiko RPC 2026" +seo_description: "{{best_name}} leads free Taiko RPC at {{best_p50}} (eth_blockNumber p50, 24h). 4 no-key providers measured every 15s from 3 regions." +subtitle: HTTP round-trip latency for eth_blockNumber against every free, no-key public Taiko RPC endpoint, audited every 15 seconds from 3 regions. + +category: RPCs +status: live +metric: RPC latency +unit: ms +higher_is_better: false + +seo_intro: | + Taiko, the based rollup where Ethereum validators sequence L2 blocks, fields 4 no-key providers. Our verification sweep caught one routing quirk worth encoding: Tenderly serves the chain only at the `taiko-mainnet` gateway slug, the plain `/taiko` path 404s. Probes run every 15 seconds from us-east, eu-west and Singapore with stale-head detection against the cross-provider tip. + +abstract: | + Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_blockNumber`) against every no-key public Taiko endpoint that sustains continuous probing, 4 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, so the leaderboard rewards sustained, honest availability rather than a fast error message. The cross-chain view lives on the parent rpc-capabilities benchmark; this page is the Taiko-scoped answer with per-region breakdowns as a first-class dimension. + +methodology: + - "Cadence: every 15 seconds per provider, from each of 3 probe regions (us-east Virginia, eu-west Amsterdam, sgp Singapore). Headline p50/p90/p99 aggregate across all 3 regions via Prometheus `avg(quantile_over_time(...))`; per-region breakdowns are first-class on this page via the region tabs." + - "Payload: `{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_blockNumber\",\"params\":[]}`. Plain HTTP POST, identical for every endpoint, no API key in any request." + - "Latency: client-side round-trip delta in milliseconds, exposed as both a gauge and a histogram (buckets 50 ms → 10 s), so percentiles are computed via Prometheus `quantile_over_time` over the last 24 hours." + - "Call-result classification: `ok` (HTTP 200 + non-empty result), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 20 blocks behind the cross-provider tip), `timeout`. Latency without reliability is a misleading ranking signal." + - "Archive depth: every 5 minutes we issue `eth_getBalance` at (head − depth) for depths from Geth's default pruned cap up to 5M blocks, exposing which free endpoints actually serve historical state." + - "This page is part of the per-chain RPC cluster derived from the cross-chain [rpc-capabilities](https://openchainbench.com/benchmarks/rpc-capabilities) benchmark; the identical harness, methodology and exclusion rules apply on every chain." + - "Chain scope: every query on this page is pinned to chain=\"taiko\". Provider coverage: 4 no-key endpoints (PublicNode, dRPC, Tenderly, Taiko). Exclusions follow the cluster-wide rules documented on the parent benchmark." + +findings: + - "{{best_name}} currently leads free Taiko RPC at {{best_p50}} (`eth_blockNumber` p50, 24h) across 4 measured providers." + - "{{name:drpc}} ({{p50:drpc}}) brings the same anycast steadiness that carries it to 10 wins across the 12 expansion chains; based sequencing on the chain side changes nothing about who answers RPC reads fastest." + - "{{name:tenderly}} both qualifies and disappoints: reachable only at the `taiko-mainnet` slug, and once reached it shows the flat ~330 ms three-region signature of a single origin, while the same gateway is competitive on the majors." + - "A 4-provider field leaves little redundancy: one gateway incident visibly reshuffles the 24h board, which is why the success-rate column and region tabs matter more here than on the deep Ethereum cohort." + +faq: + - q: "What is the fastest free Taiko RPC right now?" + a: "{{best_name}} currently leads at {{best_p50}} (`eth_blockNumber` p50 over the last 24h), measured against 4 no-key providers probed every 15 seconds from us-east, eu-west and Singapore. The leaderboard re-sorts continuously against fresh Prometheus samples, so the answer on this page is the answer right now, not a quarterly snapshot. Use the region tabs to see the leader from the origin closest to your deployment." + - q: "Which Taiko RPCs work without an API key?" + a: "The 4 providers on this page: PublicNode, dRPC, Tenderly, Taiko. Every (provider, chain) pair was live-verified no-key before inclusion, and anything that key-gates, region-blocks or rate-limits below our 15-second cadence is excluded rather than listed with an asterisk." + - q: "Does the fastest Taiko RPC change by region?" + a: "Frequently. The headline number averages three probe origins (us-east, eu-west, Singapore), but per-region leaders regularly diverge, a gateway that wins from Virginia can lose from Singapore by multiples. The region tabs at the top of the page re-scope every number on the page to a single origin; pick the one closest to where your requests actually originate." + - q: "How is Taiko RPC latency measured here?" + a: "One identical JSON-RPC POST (`eth_blockNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. Wall-clock round-trip is recorded at millisecond precision; p50/p90/p99 are computed via Prometheus `quantile_over_time` over 24 hours. Responses are classified (`ok` / `http_err` / `jsonrpc_err` / `stale` / `timeout`) so an endpoint stuck on an old head or returning errors behind HTTP 200 is never ranked as fastest. The harness is open source and every number on this page is a public Prometheus query you can run yourself." + - q: "Which free Taiko RPC should production traffic use?" + a: "Start from {{best_name}} ({{best_p50}} on the 3-region average) and pair it with the runner-up as fallback; in a 4-provider field a single degradation reshuffles the board. If you configure Tenderly manually, note its gateway path is `taiko-mainnet`, the intuitive `/taiko` path 404s, a detail our probes encode but provider directories rarely do." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/rpc-capabilities + +prometheus: + window: 24h + freshness_metric: rpc_latency_milliseconds + +# Per-cell (region) ranking matrix for scoped badge claims. Chain is +# fixed for the whole bench, so cells key on region alone. +rank_matrix_query: avg by (provider, region) (ocb:rpc_latency_milliseconds:p50_24h{chain="taiko"}) + +# Region is the only dimension: chain is baked into every query. +dimensions: + region: + - { value: all, label: All regions } + - { value: us-east, label: US-East } + - { value: eu-west, label: EU-West } + - { value: sgp, label: Singapore } + +providers: + - slug: publicnode + name: PublicNode + tag: Allnodes-operated, 70+ chains, archive on most + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Taiko endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="taiko"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="publicnode", chain="taiko"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="publicnode", chain="taiko"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="publicnode", chain="taiko"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="publicnode", chain="taiko"}) / sum(ocb:rpc_call:rate_24h{provider="publicnode", chain="taiko"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="publicnode", chain="taiko"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="taiko"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="taiko", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="taiko", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="taiko", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="taiko", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="taiko", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="taiko", region="sgp"}[1h]) + + - slug: drpc + name: dRPC + tag: Decentralized RPC mesh, consensus-checked + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Taiko endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="taiko"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="taiko"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="drpc", chain="taiko"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="drpc", chain="taiko"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="drpc", chain="taiko"}) / sum(ocb:rpc_call:rate_24h{provider="drpc", chain="taiko"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="drpc", chain="taiko"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="taiko"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="taiko", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="taiko", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="taiko", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="taiko", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="taiko", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="taiko", region="sgp"}[1h]) + + - slug: tenderly + name: Tenderly + tag: Multi-chain public gateway, no key + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key Taiko endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="taiko"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="tenderly", chain="taiko"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="tenderly", chain="taiko"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="tenderly", chain="taiko"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="tenderly", chain="taiko"}) / sum(ocb:rpc_call:rate_24h{provider="tenderly", chain="taiko"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="tenderly", chain="taiko"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="taiko"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="taiko", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="taiko", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="taiko", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="taiko", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="taiko", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="taiko", region="sgp"}[1h]) + + - slug: taiko-official + name: Taiko + tag: Taiko Labs public RPC, Taiko mainnet only + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Taiko's no-key Taiko endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="taiko-official", chain="taiko"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="taiko-official", chain="taiko"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="taiko-official", chain="taiko"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="taiko-official", chain="taiko"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="taiko-official", chain="taiko"}) / sum(ocb:rpc_call:rate_24h{provider="taiko-official", chain="taiko"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="taiko-official", chain="taiko"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="taiko-official", chain="taiko"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="taiko-official", chain="taiko", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="taiko-official", chain="taiko", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="taiko-official", chain="taiko", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="taiko-official", chain="taiko", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="taiko-official", chain="taiko", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="taiko-official", chain="taiko", region="sgp"}[1h]) + diff --git a/benchmarks/unichain-rpc.yml b/benchmarks/unichain-rpc.yml new file mode 100644 index 00000000..5f0e574c --- /dev/null +++ b/benchmarks/unichain-rpc.yml @@ -0,0 +1,182 @@ +# OpenChainBench. Bench № 059 + +slug: unichain-rpc +number: "059" +title: Fastest free Unichain RPC, live no-key endpoint latency +seo_title: "Fastest free Unichain RPC 2026" +seo_description: "{{best_name}} leads free Unichain RPC at {{best_p50}} (eth_blockNumber p50, 24h). 5 no-key providers measured every 15s from 3 regions." +subtitle: HTTP round-trip latency for eth_blockNumber against every free, no-key public Unichain RPC endpoint, audited every 15 seconds from 3 regions. + +category: RPCs +status: live +metric: RPC latency +unit: ms +higher_is_better: false + +seo_intro: | + Unichain, Uniswap Labs' OP Stack rollup, fields 5 no-key providers including the house `mainnet.unichain.org`. For a chain this young the gateway coverage is unusually complete, PublicNode, dRPC, 1RPC and Tenderly all sustain no-key probing at our 15-second cadence, so the official endpoint faces a full gateway tier from day one. Three regions, identical probes, stale-head detection. + +abstract: | + Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_blockNumber`) against every no-key public Unichain endpoint that sustains continuous probing, 5 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, so the leaderboard rewards sustained, honest availability rather than a fast error message. The cross-chain view lives on the parent rpc-capabilities benchmark; this page is the Unichain-scoped answer with per-region breakdowns as a first-class dimension. + +methodology: + - "Cadence: every 15 seconds per provider, from each of 3 probe regions (us-east Virginia, eu-west Amsterdam, sgp Singapore). Headline p50/p90/p99 aggregate across all 3 regions via Prometheus `avg(quantile_over_time(...))`; per-region breakdowns are first-class on this page via the region tabs." + - "Payload: `{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_blockNumber\",\"params\":[]}`. Plain HTTP POST, identical for every endpoint, no API key in any request." + - "Latency: client-side round-trip delta in milliseconds, exposed as both a gauge and a histogram (buckets 50 ms → 10 s), so percentiles are computed via Prometheus `quantile_over_time` over the last 24 hours." + - "Call-result classification: `ok` (HTTP 200 + non-empty result), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 20 blocks behind the cross-provider tip), `timeout`. Latency without reliability is a misleading ranking signal." + - "Archive depth: every 5 minutes we issue `eth_getBalance` at (head − depth) for depths from Geth's default pruned cap up to 5M blocks, exposing which free endpoints actually serve historical state." + - "This page is part of the per-chain RPC cluster derived from the cross-chain [rpc-capabilities](https://openchainbench.com/benchmarks/rpc-capabilities) benchmark; the identical harness, methodology and exclusion rules apply on every chain." + - "Chain scope: every query on this page is pinned to chain=\"unichain\". Provider coverage: 5 no-key endpoints (PublicNode, dRPC, 1RPC, Tenderly, Unichain). Exclusions follow the cluster-wide rules documented on the parent benchmark." + +findings: + - "{{best_name}} currently leads free Unichain RPC at {{best_p50}} (`eth_blockNumber` p50, 24h) across 5 measured providers." + - "{{name:drpc}} ({{p50:drpc}}) treats Unichain like every other chain in the expansion, and that is the point: 10 of 12 long-tail wins on the 3-region average come from routing every probe to a nearby edge, chain age irrelevant." + - "The official sequencer-adjacent endpoint has the locational advantage on paper; the region tabs show whether it holds against gateways that terminate at the probe's nearest edge instead of one home region." + - "{{name:tenderly}} repeats its long-tail signature here, roughly 330 ms from all three origins at once, while remaining competitive on the majors, the clearest sign its public gateway routes small chains through a single origin." + +faq: + - q: "What is the fastest free Unichain RPC right now?" + a: "{{best_name}} currently leads at {{best_p50}} (`eth_blockNumber` p50 over the last 24h), measured against 5 no-key providers probed every 15 seconds from us-east, eu-west and Singapore. The leaderboard re-sorts continuously against fresh Prometheus samples, so the answer on this page is the answer right now, not a quarterly snapshot. Use the region tabs to see the leader from the origin closest to your deployment." + - q: "Which Unichain RPCs work without an API key?" + a: "The 5 providers on this page: PublicNode, dRPC, 1RPC, Tenderly, Unichain. Every (provider, chain) pair was live-verified no-key before inclusion, and anything that key-gates, region-blocks or rate-limits below our 15-second cadence is excluded rather than listed with an asterisk." + - q: "Does the fastest Unichain RPC change by region?" + a: "Frequently. The headline number averages three probe origins (us-east, eu-west, Singapore), but per-region leaders regularly diverge, a gateway that wins from Virginia can lose from Singapore by multiples. The region tabs at the top of the page re-scope every number on the page to a single origin; pick the one closest to where your requests actually originate." + - q: "How is Unichain RPC latency measured here?" + a: "One identical JSON-RPC POST (`eth_blockNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. Wall-clock round-trip is recorded at millisecond precision; p50/p90/p99 are computed via Prometheus `quantile_over_time` over 24 hours. Responses are classified (`ok` / `http_err` / `jsonrpc_err` / `stale` / `timeout`) so an endpoint stuck on an old head or returning errors behind HTTP 200 is never ranked as fastest. The harness is open source and every number on this page is a public Prometheus query you can run yourself." + - q: "Should I use mainnet.unichain.org or a gateway for Unichain?" + a: "Check the region tab nearest your deployment. Chain-official endpoints are typically a single origin, so they can only be close to one of our three probes, while anycast gateways answer everywhere. On the 3-region average the current leader is {{best_name}} at {{best_p50}}; a primary-plus-fallback pair from the top of this page is the resilient default for a chain this young." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/rpc-capabilities + +prometheus: + window: 24h + freshness_metric: rpc_latency_milliseconds + +# Per-cell (region) ranking matrix for scoped badge claims. Chain is +# fixed for the whole bench, so cells key on region alone. +rank_matrix_query: avg by (provider, region) (ocb:rpc_latency_milliseconds:p50_24h{chain="unichain"}) + +# Region is the only dimension: chain is baked into every query. +dimensions: + region: + - { value: all, label: All regions } + - { value: us-east, label: US-East } + - { value: eu-west, label: EU-West } + - { value: sgp, label: Singapore } + +providers: + - slug: publicnode + name: PublicNode + tag: Allnodes-operated, 70+ chains, archive on most + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key Unichain endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="unichain"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="publicnode", chain="unichain"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="publicnode", chain="unichain"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="publicnode", chain="unichain"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="publicnode", chain="unichain"}) / sum(ocb:rpc_call:rate_24h{provider="publicnode", chain="unichain"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="publicnode", chain="unichain"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="unichain"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="unichain", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="unichain", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="unichain", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="unichain", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="publicnode", chain="unichain", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", chain="unichain", region="sgp"}[1h]) + + - slug: drpc + name: dRPC + tag: Decentralized RPC mesh, consensus-checked + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key Unichain endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="unichain"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="unichain"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="drpc", chain="unichain"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="drpc", chain="unichain"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="drpc", chain="unichain"}) / sum(ocb:rpc_call:rate_24h{provider="drpc", chain="unichain"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="drpc", chain="unichain"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="unichain"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="unichain", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="unichain", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="unichain", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="unichain", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="unichain", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="unichain", region="sgp"}[1h]) + + - slug: 1rpc + name: 1RPC + tag: Privacy-preserving gateway by Automata Network + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to 1RPC's no-key Unichain endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="unichain"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="1rpc", chain="unichain"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="1rpc", chain="unichain"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="1rpc", chain="unichain"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="1rpc", chain="unichain"}) / sum(ocb:rpc_call:rate_24h{provider="1rpc", chain="unichain"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="1rpc", chain="unichain"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="1rpc", chain="unichain"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="unichain", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="1rpc", chain="unichain", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="unichain", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="1rpc", chain="unichain", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="unichain", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="1rpc", chain="unichain", region="sgp"}[1h]) + + - slug: tenderly + name: Tenderly + tag: Multi-chain public gateway, no key + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key Unichain endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="unichain"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="tenderly", chain="unichain"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="tenderly", chain="unichain"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="tenderly", chain="unichain"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="tenderly", chain="unichain"}) / sum(ocb:rpc_call:rate_24h{provider="tenderly", chain="unichain"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="tenderly", chain="unichain"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="unichain"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="unichain", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="unichain", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="unichain", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="unichain", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="unichain", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="unichain", region="sgp"}[1h]) + + - slug: unichain-official + name: Unichain + tag: Uniswap Labs public RPC, Unichain mainnet only + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Unichain's no-key Unichain endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="unichain-official", chain="unichain"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="unichain-official", chain="unichain"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="unichain-official", chain="unichain"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="unichain-official", chain="unichain"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="unichain-official", chain="unichain"}) / sum(ocb:rpc_call:rate_24h{provider="unichain-official", chain="unichain"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="unichain-official", chain="unichain"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="unichain-official", chain="unichain"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="unichain-official", chain="unichain", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="unichain-official", chain="unichain", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="unichain-official", chain="unichain", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="unichain-official", chain="unichain", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="unichain-official", chain="unichain", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="unichain-official", chain="unichain", region="sgp"}[1h]) + diff --git a/benchmarks/zksync-rpc.yml b/benchmarks/zksync-rpc.yml new file mode 100644 index 00000000..7f2618c7 --- /dev/null +++ b/benchmarks/zksync-rpc.yml @@ -0,0 +1,159 @@ +# OpenChainBench. Bench № 063 + +slug: zksync-rpc +number: "063" +title: Fastest free zkSync Era RPC, live no-key endpoint latency +seo_title: "Fastest free zkSync RPC 2026" +seo_description: "{{best_name}} leads free zkSync Era RPC at {{best_p50}} (eth_blockNumber p50, 24h). 4 no-key providers measured every 15s from 3 regions." +subtitle: HTTP round-trip latency for eth_blockNumber against every free, no-key public zkSync Era RPC endpoint, audited every 15 seconds from 3 regions. + +category: RPCs +status: live +metric: RPC latency +unit: ms +higher_is_better: false + +seo_intro: | + zkSync Era is the only chain in our entire probe matrix with no PublicNode endpoint: both plausible subdomains 404, a genuine rarity for a provider that covers 70+ chains. That leaves dRPC, 1RPC, Tenderly and Matter Labs' own `mainnet.era.zksync.io` answering the identical `eth_blockNumber` probe every 15 seconds from three regions, with stale-head detection against the cross-provider tip. + +abstract: | + Per-chain member of the RPC latency cluster. We measure the round-trip latency of a single, identical RPC call (`eth_blockNumber`) against every no-key public zkSync Era endpoint that sustains continuous probing, 4 providers, every 15 seconds, from us-east, eu-west and Singapore. The harness also classifies every response (ok / http_err / jsonrpc_err / stale / timeout) and audits archive depth every 5 minutes, so the leaderboard rewards sustained, honest availability rather than a fast error message. The cross-chain view lives on the parent rpc-capabilities benchmark; this page is the zkSync Era-scoped answer with per-region breakdowns as a first-class dimension. + +methodology: + - "Cadence: every 15 seconds per provider, from each of 3 probe regions (us-east Virginia, eu-west Amsterdam, sgp Singapore). Headline p50/p90/p99 aggregate across all 3 regions via Prometheus `avg(quantile_over_time(...))`; per-region breakdowns are first-class on this page via the region tabs." + - "Payload: `{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_blockNumber\",\"params\":[]}`. Plain HTTP POST, identical for every endpoint, no API key in any request." + - "Latency: client-side round-trip delta in milliseconds, exposed as both a gauge and a histogram (buckets 50 ms → 10 s), so percentiles are computed via Prometheus `quantile_over_time` over the last 24 hours." + - "Call-result classification: `ok` (HTTP 200 + non-empty result), `http_err`, `jsonrpc_err` (HTTP 200 carrying an error body), `stale` (more than 20 blocks behind the cross-provider tip), `timeout`. Latency without reliability is a misleading ranking signal." + - "Archive depth: every 5 minutes we issue `eth_getBalance` at (head − depth) for depths from Geth's default pruned cap up to 5M blocks, exposing which free endpoints actually serve historical state." + - "This page is part of the per-chain RPC cluster derived from the cross-chain [rpc-capabilities](https://openchainbench.com/benchmarks/rpc-capabilities) benchmark; the identical harness, methodology and exclusion rules apply on every chain." + - "Chain scope: every query on this page is pinned to chain=\"zksync\". Provider coverage: 4 no-key endpoints (dRPC, 1RPC, Tenderly, zkSync). Exclusions follow the cluster-wide rules documented on the parent benchmark." + +findings: + - "{{best_name}} currently leads free zkSync Era RPC at {{best_p50}} (`eth_blockNumber` p50, 24h) across 4 measured providers." + - "No PublicNode is the structural headline: the near-universal default provider simply does not serve zkSync Era (both candidate subdomains 404), so dapps that template PublicNode URLs per chain need a different answer here." + - "{{name:drpc}} ({{p50:drpc}}) picks up the default-provider role instead, with the anycast consistency that wins it 10 of the 12 expansion chains on the 3-region average." + - "{{name:tenderly}} runs zkSync through the same single-origin path as the rest of the long tail, a flat ~330 ms from all three regions, despite marketing the chain as a first-class network." + +faq: + - q: "What is the fastest free zkSync Era RPC right now?" + a: "{{best_name}} currently leads at {{best_p50}} (`eth_blockNumber` p50 over the last 24h), measured against 4 no-key providers probed every 15 seconds from us-east, eu-west and Singapore. The leaderboard re-sorts continuously against fresh Prometheus samples, so the answer on this page is the answer right now, not a quarterly snapshot. Use the region tabs to see the leader from the origin closest to your deployment." + - q: "Which zkSync Era RPCs work without an API key?" + a: "The 4 providers on this page: dRPC, 1RPC, Tenderly, zkSync. Every (provider, chain) pair was live-verified no-key before inclusion, and anything that key-gates, region-blocks or rate-limits below our 15-second cadence is excluded rather than listed with an asterisk." + - q: "Does the fastest zkSync Era RPC change by region?" + a: "Frequently. The headline number averages three probe origins (us-east, eu-west, Singapore), but per-region leaders regularly diverge, a gateway that wins from Virginia can lose from Singapore by multiples. The region tabs at the top of the page re-scope every number on the page to a single origin; pick the one closest to where your requests actually originate." + - q: "How is zkSync Era RPC latency measured here?" + a: "One identical JSON-RPC POST (`eth_blockNumber`) every 15 seconds against each provider from each of 3 regions, with the same plain HTTP client. Wall-clock round-trip is recorded at millisecond precision; p50/p90/p99 are computed via Prometheus `quantile_over_time` over 24 hours. Responses are classified (`ok` / `http_err` / `jsonrpc_err` / `stale` / `timeout`) so an endpoint stuck on an old head or returning errors behind HTTP 200 is never ranked as fastest. The harness is open source and every number on this page is a public Prometheus query you can run yourself." + - q: "Why is PublicNode not listed for zkSync Era?" + a: "Because it does not serve the chain: both plausible PublicNode subdomains returned 404 in our 2026-07-03 verification sweep, making zkSync Era the only chain we probe without a PublicNode endpoint. The bench lists what actually answers, not what coverage pages claim, so the leaderboard has 4 providers and {{best_name}} ({{best_p50}}) currently leads them." + +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/rpc-capabilities + +prometheus: + window: 24h + freshness_metric: rpc_latency_milliseconds + +# Per-cell (region) ranking matrix for scoped badge claims. Chain is +# fixed for the whole bench, so cells key on region alone. +rank_matrix_query: avg by (provider, region) (ocb:rpc_latency_milliseconds:p50_24h{chain="zksync"}) + +# Region is the only dimension: chain is baked into every query. +dimensions: + region: + - { value: all, label: All regions } + - { value: us-east, label: US-East } + - { value: eu-west, label: EU-West } + - { value: sgp, label: Singapore } + +providers: + - slug: drpc + name: dRPC + tag: Decentralized RPC mesh, consensus-checked + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's no-key zkSync Era endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="zksync"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="drpc", chain="zksync"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="drpc", chain="zksync"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="drpc", chain="zksync"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="drpc", chain="zksync"}) / sum(ocb:rpc_call:rate_24h{provider="drpc", chain="zksync"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="drpc", chain="zksync"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="zksync"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="zksync", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="zksync", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="zksync", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="zksync", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="drpc", chain="zksync", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", chain="zksync", region="sgp"}[1h]) + + - slug: 1rpc + name: 1RPC + tag: Privacy-preserving gateway by Automata Network + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to 1RPC's no-key zkSync Era endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="zksync"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="1rpc", chain="zksync"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="1rpc", chain="zksync"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="1rpc", chain="zksync"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="1rpc", chain="zksync"}) / sum(ocb:rpc_call:rate_24h{provider="1rpc", chain="zksync"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="1rpc", chain="zksync"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="1rpc", chain="zksync"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="zksync", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="1rpc", chain="zksync", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="zksync", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="1rpc", chain="zksync", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="1rpc", chain="zksync", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="1rpc", chain="zksync", region="sgp"}[1h]) + + - slug: tenderly + name: Tenderly + tag: Multi-chain public gateway, no key + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Tenderly's no-key zkSync Era endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="zksync"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="tenderly", chain="zksync"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="tenderly", chain="zksync"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="tenderly", chain="zksync"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="tenderly", chain="zksync"}) / sum(ocb:rpc_call:rate_24h{provider="tenderly", chain="zksync"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="tenderly", chain="zksync"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="zksync"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="zksync", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="zksync", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="zksync", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="zksync", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="tenderly", chain="zksync", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="tenderly", chain="zksync", region="sgp"}[1h]) + + - slug: zksync-official + name: zkSync + tag: Matter Labs public RPC, zkSync Era only + formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to zkSync's no-key zkSync Era endpoint." + queries: + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="zksync-official", chain="zksync"}) + p90: avg(ocb:rpc_latency_milliseconds:p90_24h{provider="zksync-official", chain="zksync"}) + p99: avg(ocb:rpc_latency_milliseconds:p99_24h{provider="zksync-official", chain="zksync"}) + mean: avg(ocb:rpc_latency_milliseconds:mean_24h{provider="zksync-official", chain="zksync"}) + success: sum(ocb:rpc_call:ok_rate_24h{provider="zksync-official", chain="zksync"}) / sum(ocb:rpc_call:rate_24h{provider="zksync-official", chain="zksync"}) + sample_size: sum(ocb:rpc_call:increase_24h{provider="zksync-official", chain="zksync"}) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="zksync-official", chain="zksync"}[1h])) + regions: + - region: us-east + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="zksync-official", chain="zksync", region="us-east"}) + series: avg_over_time(rpc_latency_milliseconds{provider="zksync-official", chain="zksync", region="us-east"}[1h]) + - region: eu-west + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="zksync-official", chain="zksync", region="eu-west"}) + series: avg_over_time(rpc_latency_milliseconds{provider="zksync-official", chain="zksync", region="eu-west"}[1h]) + - region: ap-southeast + p50: avg(ocb:rpc_latency_milliseconds:p50_24h{provider="zksync-official", chain="zksync", region="sgp"}) + series: avg_over_time(rpc_latency_milliseconds{provider="zksync-official", chain="zksync", region="sgp"}[1h]) + diff --git a/scripts/generate-rpc-chain-benches.py b/scripts/generate-rpc-chain-benches.py index c6705330..6476efcb 100644 --- a/scripts/generate-rpc-chain-benches.py +++ b/scripts/generate-rpc-chain-benches.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 -"""One-shot generator for the per-chain RPC benchmark cluster (044-053). +"""Generator for the per-chain RPC benchmark cluster (044-053 majors, +055-066 long-tail expansion of 2026-07-03). Reads provider metadata from benchmarks/rpc-capabilities.yml (the parent, which stays live as the cross-chain index) and emits one first-class @@ -30,8 +31,54 @@ "linea": {"num": "051", "label": "Linea", "providers": ["publicnode", "drpc", "1rpc", "tenderly"]}, "scroll": {"num": "052", "label": "Scroll", "providers": ["publicnode", "drpc", "1rpc", "tenderly"]}, "mantle": {"num": "053", "label": "Mantle", "providers": ["publicnode", "drpc", "1rpc", "tenderly"]}, + # Long-tail expansion (2026-07-03 sweep, provider order mirrors + # harnesses/rpc-capabilities/cmd/script/config.go). + "sonic": {"num": "055", "label": "Sonic", "providers": ["publicnode", "drpc", "1rpc", "tenderly", "lava", "sonic-official"]}, + "gnosis": {"num": "056", "label": "Gnosis", "providers": ["publicnode", "drpc", "1rpc", "tenderly", "nodies", "gnosis-official"]}, + "celo": {"num": "057", "label": "Celo", "providers": ["publicnode", "drpc", "1rpc", "tenderly", "celo-official"]}, + "moonbeam": {"num": "058", "label": "Moonbeam", "providers": ["publicnode", "drpc", "1rpc", "tenderly", "moonbeam-official"]}, + "unichain": {"num": "059", "label": "Unichain", "providers": ["publicnode", "drpc", "1rpc", "tenderly", "unichain-official"]}, + "blast": {"num": "060", "label": "Blast", "providers": ["publicnode", "drpc", "tenderly", "blast-official"]}, + "taiko": {"num": "061", "label": "Taiko", "providers": ["publicnode", "drpc", "tenderly", "taiko-official"]}, + "berachain": {"num": "062", "label": "Berachain", "providers": ["publicnode", "drpc", "tenderly", "berachain-official"]}, + # seo_label keeps "Fastest free zkSync RPC 2026" inside the 26-31 + # char window ("zkSync Era" pushes it to 32). + "zksync": {"num": "063", "label": "zkSync Era", "seo_label": "zkSync", "providers": ["drpc", "1rpc", "tenderly", "zksync-official"]}, + "cronos": {"num": "064", "label": "Cronos", "providers": ["publicnode", "drpc", "1rpc", "cronos-official"]}, + "fraxtal": {"num": "065", "label": "Fraxtal", "providers": ["publicnode", "drpc", "tenderly", "fraxtal-official"]}, + "soneium": {"num": "066", "label": "Soneium", "providers": ["publicnode", "drpc", "tenderly", "soneium-official"]}, } +# Provider metadata for endpoints that only exist on the long-tail +# chains (slugs mirror config.go; the parent rpc-capabilities.yml only +# carries the majors-era roster). Names follow the site convention for +# chain-official endpoints: brand name, "official" lives in the tag. +EXTRA_PROVIDERS = { + "sonic-official": {"name": "Sonic Labs", "tag": "Sonic Labs public RPC, Sonic mainnet only"}, + "gnosis-official": {"name": "Gnosis", "tag": "Gnosis chain-official RPC (rpc.gnosischain.com)"}, + "celo-official": {"name": "Celo (Forno)", "tag": "cLabs Forno public RPC, Celo mainnet only"}, + "moonbeam-official": {"name": "Moonbeam", "tag": "Moonbeam Foundation public RPC, Moonbeam only"}, + "unichain-official": {"name": "Unichain", "tag": "Uniswap Labs public RPC, Unichain mainnet only"}, + "blast-official": {"name": "Blast", "tag": "Chain-official RPC, edge-terminated (see findings)"}, + "taiko-official": {"name": "Taiko", "tag": "Taiko Labs public RPC, Taiko mainnet only"}, + "berachain-official": {"name": "Berachain", "tag": "Berachain Foundation public RPC, Berachain only"}, + "zksync-official": {"name": "zkSync", "tag": "Matter Labs public RPC, zkSync Era only"}, + "cronos-official": {"name": "Cronos", "tag": "Cronos Labs public RPC, Cronos EVM only"}, + "fraxtal-official": {"name": "Fraxtal", "tag": "Frax-operated public RPC, Fraxtal mainnet only"}, + "soneium-official": {"name": "Soneium", "tag": "Sony Block Solutions public RPC, Soneium mainnet only"}, +} + +# Per-(chain, provider) tag overrides. The parent-yml tags encode +# majors-era facts (Tenderly "9 chains", Lava "ETH + Arbitrum no-key") +# that are stale on the long-tail pages; existing 044-053 output must +# stay byte-identical, so the corrections apply per chain here. +TAG_OVERRIDES = { + ("sonic", "lava"): "Decentralized permissionless RPC mesh (sonic.lava.build, open no-key)", +} +for _c in ("sonic", "gnosis", "celo", "moonbeam", "unichain", "blast", + "taiko", "berachain", "zksync", "fraxtal", "soneium"): + TAG_OVERRIDES[(_c, "tenderly")] = "Multi-chain public gateway, no key" + # Chain-specific editorial. Every string is unique to its chain so the # cluster never reads as one duplicated template. EDITORIAL = { @@ -137,6 +184,138 @@ "faq_extra_q": "Are free Mantle RPCs reliable enough to build on?", "faq_extra_a": "The four qualifying gateways all maintain high measured success rates on Mantle, but a 4-provider field means less redundancy if one degrades. Use the current leader as primary and the runner-up as fallback, and re-check this page after incidents, the ranking is live and the honest answer moves.", }, + "sonic": { + "intro": "Sonic ties Gnosis for the largest cohort in the long-tail expansion: 6 no-key providers, including Sonic Labs' own `rpc.soniclabs.com` and the only keyless Lava endpoint outside Ethereum and Arbitrum, `sonic.lava.build` answers no-key while every other Lava subdomain 403s without an API key. Every provider gets the identical `eth_blockNumber` probe every 15 seconds from us-east, eu-west and Singapore, with stale-head detection against the cross-provider tip.", + "findings": [ + "{{best_name}} currently leads free Sonic RPC at {{best_p50}} (`eth_blockNumber` p50, 24h) across 6 measured providers.", + "{{name:drpc}} ({{p50:drpc}}) illustrates the long-tail pattern: it wins 10 of the 12 chains in this expansion on the 3-region average, not by posting the fastest single-region peak but by answering every origin from a nearby anycast edge.", + "{{name:tenderly}} tells the opposite story: roughly 330 ms in every region, the flat signature of single-origin routing. The same gateway is competitive on Ethereum and Base, so this is a per-chain routing decision, not a capacity problem.", + "{{name:lava}} makes Sonic a curiosity: `sonic.lava.build` is the only Lava subdomain that answers no-key outside eth1/arb1, so this page is the cluster's only long-tail read on the Lava mesh.", + ], + "faq_extra_q": "Why does Lava appear on Sonic but on no other long-tail chain?", + "faq_extra_a": "Lava publishes `*.lava.build` subdomains for many chains, but nearly all of them return 403 without an API key. `sonic.lava.build` is the exception: it passed our no-key verification (eth_chainId match plus sustained probing) and has held the 15-second cadence since. Every (provider, chain) pair in this cluster is admitted on measured behavior, not on a provider's published chain list.", + }, + "gnosis": { + "intro": "Gnosis is the cluster's clearest proof that official does not mean fast: the chain-official `rpc.gnosischain.com` is the slowest endpoint we measure on the chain, around 433 ms p50 on the 3-region average, while five third-party gateways beat it, including Nodies, whose POKT-backed infrastructure reaches Gnosis as its only chain in this 12-chain expansion. 6 no-key providers, the same `eth_blockNumber` call every 15 seconds, three regions.", + "findings": [ + "{{best_name}} currently leads free Gnosis RPC at {{best_p50}} (`eth_blockNumber` p50, 24h) across 6 measured providers.", + "The chain-official endpoint anchors the wrong end of the board: ~433 ms p50 with a similar profile from every region, slower than every gateway on this page. It is honest about its blocks; it is just slow.", + "{{name:nodies}} ({{p50:nodies}}) is the quiet story of the expansion: Gnosis is the only long-tail chain it qualifies on, and it serves the chain well, a POKT-routed gateway beating the house endpoint by a wide margin.", + "{{name:drpc}} ({{p50:drpc}}) shows its usual anycast consistency here, part of the pattern that has it leading 10 of the 12 long-tail chains on the 3-region average.", + ], + "faq_extra_q": "Should I use rpc.gnosischain.com as my Gnosis RPC?", + "faq_extra_a": "Only as a fallback. It is the slowest endpoint we measure on Gnosis, roughly 433 ms median across three regions, several times the gateway tier, though its reliability and head freshness are fine. The measured leaders above serve the same chain with a fraction of the round trip; keep the official endpoint in the rotation for redundancy rather than as primary.", + }, + "celo": { + "intro": "Celo brings 5 no-key providers anchored by Forno (`forno.celo.org`), cLabs' public endpoint that predates most of the gateway industry. Since Celo's migration to an Ethereum L2 the RPC surface is standard EVM, so the multi-chain gateways (PublicNode, dRPC, 1RPC, Tenderly) compete directly with the house endpoint on the identical `eth_blockNumber` probe every 15 seconds from three regions.", + "findings": [ + "{{best_name}} currently leads free Celo RPC at {{best_p50}} (`eth_blockNumber` p50, 24h) across 5 measured providers.", + "{{name:drpc}} ({{p50:drpc}}) extends its long-tail run here, 10 of the 12 expansion chains fall to it on the 3-region average, a consistency win built on anycast rather than any single-region record.", + "Forno remains a serviceable default years after launch, but it is one origin: at least two of our three probe regions always see it with an ocean in the path, which the region tabs make visible.", + "{{name:tenderly}} shows the same long-tail collapse measured across this expansion: ~330 ms flat in all three regions, single-origin routing behind a gateway that is genuinely fast on the majors.", + ], + "faq_extra_q": "Is Forno still the right default RPC for Celo?", + "faq_extra_a": "Forno is stable and honest, but it is a single origin, so at least two of our three probe regions always pay cross-ocean latency to reach it. The current leader above ({{best_name}} at {{best_p50}}) reflects the 3-region average; if your traffic is single-region, open that region's tab, Forno's ranking moves markedly by origin.", + }, + "moonbeam": { + "intro": "Moonbeam, Polkadot's EVM parachain, fields 5 no-key providers including the Moonbeam Foundation's `rpc.api.moonbeam.network`. One integration trap surfaced in our verification sweep: 1RPC addresses the chain by its token code, so the working path is `1rpc.io/glmr` and the intuitive `/moonbeam` returns HTTP 400. Probes run every 15 seconds from three regions with full response classification.", + "findings": [ + "{{best_name}} currently leads free Moonbeam RPC at {{best_p50}} (`eth_blockNumber` p50, 24h) across 5 measured providers.", + "{{name:drpc}} ({{p50:drpc}}) carries its anycast-consistency pattern onto a Polkadot parachain unchanged: same probe, same edge behavior, same 3-region steadiness that wins it 10 of the 12 long-tail chains.", + "{{name:1rpc}} is reachable only at the token-code path `1rpc.io/glmr`; the obvious `/moonbeam` URL 400s, the kind of detail no status page documents and this bench exists to encode.", + "{{name:tenderly}} posts the expansion's recurring flat ~330 ms in every region on Moonbeam too, single-origin routing rather than the edge network it runs for the major chains.", + ], + "faq_extra_q": "Why does 1RPC's Moonbeam endpoint use /glmr instead of /moonbeam?", + "faq_extra_a": "1RPC keys several chain paths on native-token tickers, and GLMR is Moonbeam's token, so the working endpoint is `1rpc.io/glmr` while `1rpc.io/moonbeam` returns HTTP 400. Our harness verified the chain identity behind the path (eth_chainId 1284) before admitting it, so the numbers above are guaranteed to be Moonbeam mainnet and not a lookalike.", + }, + "unichain": { + "intro": "Unichain, Uniswap Labs' OP Stack rollup, fields 5 no-key providers including the house `mainnet.unichain.org`. For a chain this young the gateway coverage is unusually complete, PublicNode, dRPC, 1RPC and Tenderly all sustain no-key probing at our 15-second cadence, so the official endpoint faces a full gateway tier from day one. Three regions, identical probes, stale-head detection.", + "findings": [ + "{{best_name}} currently leads free Unichain RPC at {{best_p50}} (`eth_blockNumber` p50, 24h) across 5 measured providers.", + "{{name:drpc}} ({{p50:drpc}}) treats Unichain like every other chain in the expansion, and that is the point: 10 of 12 long-tail wins on the 3-region average come from routing every probe to a nearby edge, chain age irrelevant.", + "The official sequencer-adjacent endpoint has the locational advantage on paper; the region tabs show whether it holds against gateways that terminate at the probe's nearest edge instead of one home region.", + "{{name:tenderly}} repeats its long-tail signature here, roughly 330 ms from all three origins at once, while remaining competitive on the majors, the clearest sign its public gateway routes small chains through a single origin.", + ], + "faq_extra_q": "Should I use mainnet.unichain.org or a gateway for Unichain?", + "faq_extra_a": "Check the region tab nearest your deployment. Chain-official endpoints are typically a single origin, so they can only be close to one of our three probes, while anycast gateways answer everywhere. On the 3-region average the current leader is {{best_name}} at {{best_p50}}; a primary-plus-fallback pair from the top of this page is the resilient default for a chain this young.", + }, + "blast": { + "intro": "Blast is the expansion's measurement cautionary tale. The chain-official `rpc.blast.io` answers in roughly 2 ms from all three probe regions at once, which no single origin can do: Virginia, Amsterdam and Singapore are separated by 80+ ms round trips at the speed of light. The endpoint terminates at an edge network. Our stale-head detection confirms the blocks it serves are fresh, but a sub-5 ms number measures the edge handshake, not the chain. 4 no-key providers, identical probes, three regions.", + "findings": [ + "{{best_name}} currently leads free Blast RPC at {{best_p50}} (`eth_blockNumber` p50, 24h) across 4 measured providers.", + "`rpc.blast.io` posts ~2 ms in every region simultaneously, physically impossible for one origin. Read it as an edge-terminated endpoint: heads are fresh per our stale detection, but the latency column measures CDN termination rather than a node round trip, the same class of caution we document for Cloudflare-eth.", + "{{name:drpc}} ({{p50:drpc}}) is the honest-infrastructure comparison point: anycast consistency across the three regions with real node round trips behind it, the profile that wins it 10 of the 12 expansion chains.", + "{{name:tenderly}} sits at the expansion's familiar flat ~330 ms in all regions on Blast, single-origin routing on a gateway that is genuinely quick on the major chains.", + ], + "faq_extra_q": "Is rpc.blast.io really that fast, or is something else going on?", + "faq_extra_a": "Something else. Two milliseconds simultaneously from Virginia, Amsterdam and Singapore is below the physical round-trip floor for any single origin, so the endpoint is answering at an anycast/CDN edge. Our stale-head detection shows the blocks it returns are current, so it is not serving a stale cache today, but edge termination means the latency figure describes the edge network, not node processing. We keep it ranked with the caveat documented, exactly as we do for Cloudflare's fast-but-permissioned Ethereum endpoint.", + }, + "taiko": { + "intro": "Taiko, the based rollup where Ethereum validators sequence L2 blocks, fields 4 no-key providers. Our verification sweep caught one routing quirk worth encoding: Tenderly serves the chain only at the `taiko-mainnet` gateway slug, the plain `/taiko` path 404s. Probes run every 15 seconds from us-east, eu-west and Singapore with stale-head detection against the cross-provider tip.", + "findings": [ + "{{best_name}} currently leads free Taiko RPC at {{best_p50}} (`eth_blockNumber` p50, 24h) across 4 measured providers.", + "{{name:drpc}} ({{p50:drpc}}) brings the same anycast steadiness that carries it to 10 wins across the 12 expansion chains; based sequencing on the chain side changes nothing about who answers RPC reads fastest.", + "{{name:tenderly}} both qualifies and disappoints: reachable only at the `taiko-mainnet` slug, and once reached it shows the flat ~330 ms three-region signature of a single origin, while the same gateway is competitive on the majors.", + "A 4-provider field leaves little redundancy: one gateway incident visibly reshuffles the 24h board, which is why the success-rate column and region tabs matter more here than on the deep Ethereum cohort.", + ], + "faq_extra_q": "Which free Taiko RPC should production traffic use?", + "faq_extra_a": "Start from {{best_name}} ({{best_p50}} on the 3-region average) and pair it with the runner-up as fallback; in a 4-provider field a single degradation reshuffles the board. If you configure Tenderly manually, note its gateway path is `taiko-mainnet`, the intuitive `/taiko` path 404s, a detail our probes encode but provider directories rarely do.", + }, + "berachain": { + "intro": "Berachain's proof-of-liquidity L1 fields 4 no-key providers: the Foundation's `rpc.berachain.com` plus PublicNode, dRPC and Tenderly. A thin cohort is itself a signal, several sibling chains failed the cluster's four-keyless-provider bar entirely, so each endpoint that qualifies here carries more of the redundancy burden. Identical probes every 15 seconds, three regions, full response classification.", + "findings": [ + "{{best_name}} currently leads free Berachain RPC at {{best_p50}} (`eth_blockNumber` p50, 24h) across 4 measured providers.", + "{{name:drpc}} ({{p50:drpc}}) extends its expansion-wide consistency run to Berachain, the anycast profile that takes 10 of the 12 long-tail chains on the 3-region average.", + "{{name:tenderly}} shows the long-tail single-origin signature again, roughly 330 ms from every region, a sharp contrast with its performance on the chains its edge network actually fronts.", + "The Foundation endpoint gives the chain a credible house baseline; whether it beats the gateways depends on your origin, which is exactly what the per-region tabs are for.", + ], + "faq_extra_q": "Are free Berachain RPCs ready for production traffic?", + "faq_extra_a": "The four qualifying endpoints all sustain our 15-second cadence with high measured success rates, which is the floor for production reads. The real constraint is redundancy: with 4 providers, one incident removes a quarter of your options, so run the current leader ({{best_name}}, {{best_p50}}) as primary with the runner-up wired as fallback and let this page arbitrate after incidents.", + }, + "zksync": { + "intro": "zkSync Era is the only chain in our entire probe matrix with no PublicNode endpoint: both plausible subdomains 404, a genuine rarity for a provider that covers 70+ chains. That leaves dRPC, 1RPC, Tenderly and Matter Labs' own `mainnet.era.zksync.io` answering the identical `eth_blockNumber` probe every 15 seconds from three regions, with stale-head detection against the cross-provider tip.", + "findings": [ + "{{best_name}} currently leads free zkSync Era RPC at {{best_p50}} (`eth_blockNumber` p50, 24h) across 4 measured providers.", + "No PublicNode is the structural headline: the near-universal default provider simply does not serve zkSync Era (both candidate subdomains 404), so dapps that template PublicNode URLs per chain need a different answer here.", + "{{name:drpc}} ({{p50:drpc}}) picks up the default-provider role instead, with the anycast consistency that wins it 10 of the 12 expansion chains on the 3-region average.", + "{{name:tenderly}} runs zkSync through the same single-origin path as the rest of the long tail, a flat ~330 ms from all three regions, despite marketing the chain as a first-class network.", + ], + "faq_extra_q": "Why is PublicNode not listed for zkSync Era?", + "faq_extra_a": "Because it does not serve the chain: both plausible PublicNode subdomains returned 404 in our 2026-07-03 verification sweep, making zkSync Era the only chain we probe without a PublicNode endpoint. The bench lists what actually answers, not what coverage pages claim, so the leaderboard has 4 providers and {{best_name}} ({{best_p50}}) currently leads them.", + }, + "cronos": { + "intro": "Cronos fields 4 no-key providers and encodes two integration traps from our sweep: PublicNode serves the chain at `cronos-evm-rpc.publicnode.com` (the intuitive `cronos-rpc` subdomain resolves but returns non-JSON), and 1RPC uses the ticker path `1rpc.io/cro`. Tenderly's public gateway does not reach Cronos, making this one of the few pages in the cluster without it. Probes every 15 seconds from three regions.", + "findings": [ + "{{best_name}} currently leads free Cronos RPC at {{best_p50}} (`eth_blockNumber` p50, 24h) across 4 measured providers.", + "{{name:publicnode}} ({{p50:publicnode}}) hides behind a naming trap: the working subdomain is `cronos-evm-rpc`, while `cronos-rpc` resolves and then returns non-JSON, a failure mode that looks like an outage if you guessed the URL.", + "{{name:drpc}} ({{p50:drpc}}) delivers its usual three-region steadiness, the anycast pattern behind its 10-of-12 record across this long-tail expansion.", + "MeowRPC, historically listed for Cronos in RPC directories, is absent by measurement: its long-tail DNS is gone and the provider appears defunct outside a handful of legacy chains.", + ], + "faq_extra_q": "Why isn't MeowRPC listed on Cronos?", + "faq_extra_a": "We tried it. MeowRPC's long-tail endpoints no longer resolve (DNS gone), and the provider appears defunct outside a few legacy chains, so it failed the live verification, eth_chainId match plus sustained probing, that gates admission to this cluster. Directories still listing it are copying stale metadata; this bench only ranks endpoints that answer.", + }, + "fraxtal": { + "intro": "Fraxtal, Frax's OP Stack rollup, fields 4 no-key providers: PublicNode, dRPC, Tenderly and Frax's own `rpc.frax.com`. The four-provider floor it just clears is the cluster's deliberate admission bar, chains that could not field four keyless endpoints (Mode, Zora, Abstract) were left out of the expansion entirely rather than shipped as two-row leaderboards. Identical probes every 15 seconds, three regions.", + "findings": [ + "{{best_name}} currently leads free Fraxtal RPC at {{best_p50}} (`eth_blockNumber` p50, 24h) across 4 measured providers.", + "{{name:drpc}} ({{p50:drpc}}) closes out its expansion pattern here, anycast consistency across us-east, eu-west and Singapore, the profile that wins it 10 of the 12 new chains on the 3-region average.", + "{{name:tenderly}} posts the recurring long-tail flat line, ~330 ms from every origin at once, single-origin routing on a gateway whose edge network clearly does not front Fraxtal.", + "Fraxtal sits exactly at the cluster's admission bar of 4 keyless providers, the fact that Mode, Zora and Abstract missed; a thin field makes the success-rate column the tiebreaker the median cannot show.", + ], + "faq_extra_q": "Why are chains like Mode, Zora or Sei missing from this cluster?", + "faq_extra_a": "Each failed a specific admission test. Mode, Zora and Abstract could not field 4 keyless providers, below the bar for a meaningful leaderboard. Sei was excluded because dRPC caches `eth_blockNumber` there, poisoning the exact probe we rank on. opBNB fell out because 1RPC returns 429 at our 15-second cadence, leaving only 3 solid providers. The cluster only ships chains where the comparison is honest.", + }, + "soneium": { + "intro": "Soneium, Sony's OP Stack rollup, is the long-tail chain where the official endpoint actually wins: `rpc.soneium.org` leads at roughly 17 ms on the 3-region average, distributed official infrastructure that answers near every probe origin while keeping fresh heads in our stale detection. 4 no-key providers, the identical `eth_blockNumber` call every 15 seconds from us-east, eu-west and Singapore.", + "findings": [ + "{{best_name}} currently leads free Soneium RPC at {{best_p50}} (`eth_blockNumber` p50, 24h) across 4 measured providers.", + "The official `rpc.soneium.org` is one of the expansion's two exceptions to the dRPC sweep: ~17 ms on the 3-region average with fresh heads throughout, a chain operator that fronts its RPC properly across regions instead of pointing DNS at one box.", + "{{name:drpc}} ({{p50:drpc}}) still posts its trademark three-region consistency here; it just meets the rare official endpoint built on the same playbook.", + "{{name:tenderly}} closes the pattern the expansion documents everywhere: roughly 330 ms flat in all three regions on Soneium, single-origin routing behind a gateway that is competitive on the majors.", + ], + "faq_extra_q": "Is the official Soneium RPC actually the best choice?", + "faq_extra_a": "Currently yes, and that is unusual: across the 12-chain long-tail expansion only two chains resist the gateway tier, and `rpc.soneium.org` is the clearest case, leading the 3-region average at around 17 ms while our stale-head checks stay clean. The usual free-tier caveats (no SLA, shared limits) still apply, so keep {{name:drpc}} or {{name:publicnode}} wired as fallback.", + }, } SHARED_METHO = [ @@ -207,7 +386,8 @@ def gen_chain(chain, cfg, providers_meta): n = len(cfg["providers"]) title = f"Fastest free {label} RPC, live no-key endpoint latency" - seo_title = f"Fastest free {label} RPC 2026" + seo_title = f"Fastest free {cfg.get('seo_label', label)} RPC 2026" + assert 26 <= len(seo_title) <= 31, f"{chain}: seo_title is {len(seo_title)} chars: {seo_title!r}" seo_desc = ( f"{{{{best_name}}}} leads free {label} RPC at {{{{best_p50}}}} " f"(eth_blockNumber p50, 24h). {n} no-key providers measured every 15s from 3 regions." @@ -293,13 +473,16 @@ def gen_chain(chain, cfg, providers_meta): out.append("") out.append("providers:") for p in cfg["providers"]: - out.append(provider_block(p, providers_meta[p], chain, label)) + meta = dict(providers_meta[p]) + if (chain, p) in TAG_OVERRIDES: + meta["tag"] = TAG_OVERRIDES[(chain, p)] + out.append(provider_block(p, meta, chain, label)) out.append("") return "\n".join(out) def main(): - providers_meta = parse_parent_providers() + providers_meta = {**parse_parent_providers(), **EXTRA_PROVIDERS} for chain, cfg in CHAINS.items(): missing = [p for p in cfg["providers"] if p not in providers_meta] assert not missing, f"{chain}: missing provider meta {missing}" diff --git a/src/lib/chains.ts b/src/lib/chains.ts index b27e2b6d..e2fffb08 100644 --- a/src/lib/chains.ts +++ b/src/lib/chains.ts @@ -35,7 +35,9 @@ export type ChainEntry = { * the page won't render those cards. 100% coverage today across the * 21 chains in this registry; declared explicitly so a new chain that * forgets to set it just hides the cards rather than rendering a wrong - * symbol. + * symbol. Long-tail chains added with the 055-066 RPC cluster + * (2026-07-03) set their best-known symbol; cards stay hidden until + * the chain-kpis harness sources the series. */ nativeSymbol?: string; }; @@ -209,6 +211,79 @@ export const CHAINS: ChainEntry[] = [ description: "Based rollup. Ethereum L1 validators sequence Taiko blocks directly, inheriting L1 liveness and censorship resistance.", }, + // ─── Long-tail chains added with the 055-066 RPC cluster (2026-07-03) ─── + { + slug: "sonic", + label: "Sonic", + category: "L1", + nativeSymbol: "S", + description: + "EVM Layer 1 by Sonic Labs (Fantom lineage), sub-second finality, ~0.5 s blocks, fee-monetization revenue share for apps.", + }, + { + slug: "gnosis", + label: "Gnosis", + category: "L1", + nativeSymbol: "GNO", + description: + "EVM Layer 1 with xDAI as the gas token, 5 s blocks, Gnosis Beacon Chain consensus mirroring Ethereum's PoS design.", + }, + { + slug: "celo", + label: "Celo", + category: "L2", + nativeSymbol: "CELO", + description: + "Former L1 migrated to an Ethereum L2 (OP Stack) in 2025, 1 s blocks, gas payable in CELO and whitelisted stablecoins.", + }, + { + slug: "moonbeam", + label: "Moonbeam", + category: "L1", + nativeSymbol: "GLMR", + description: + "Polkadot EVM parachain, ~6 s blocks under async backing, GLMR gas, unified Substrate + EVM account model.", + }, + { + slug: "unichain", + label: "Unichain", + category: "L2", + nativeSymbol: "ETH", + description: + "Uniswap Labs OP Stack rollup, 1 s blocks with 250 ms Flashblocks pre-confirmations, DeFi-focused sequencing.", + }, + { + slug: "berachain", + label: "Berachain", + category: "L1", + nativeSymbol: "BERA", + description: + "EVM Layer 1 on the BeaconKit stack with proof-of-liquidity consensus, ~2 s blocks, BERA gas + BGT governance split.", + }, + { + slug: "cronos", + label: "Cronos", + category: "L1", + nativeSymbol: "CRO", + description: + "Crypto.com EVM chain built on Cosmos SDK + Ethermint, ~1 s blocks, IBC connectivity, CRO-denominated gas.", + }, + { + slug: "fraxtal", + label: "Fraxtal", + category: "L2", + nativeSymbol: "FRAX", + description: + "Frax Finance OP Stack rollup, 2 s sequencer cadence, frxETH-denominated gas with FXTL incentive points.", + }, + { + slug: "soneium", + label: "Soneium", + category: "L2", + nativeSymbol: "ETH", + description: + "Sony Block Solutions OP Stack rollup in the Optimism Superchain, 2 s blocks, consumer and entertainment focus.", + }, ]; export const CHAIN_BY_SLUG = new Map(CHAINS.map((c) => [c.slug, c])); diff --git a/src/lib/logo-manifest.ts b/src/lib/logo-manifest.ts index 08c49fb7..aa205930 100644 --- a/src/lib/logo-manifest.ts +++ b/src/lib/logo-manifest.ts @@ -328,6 +328,16 @@ const ALIASES: Record = { "arbitrum-official": "arbitrum", "avalanche-official": "avalanche", "optimism-official": "optimism", + // Long-tail RPC cluster (055-066). Only chains with a registered + // brand mark are aliased; gnosis / moonbeam / unichain / cronos / + // fraxtal / soneium have no logo file yet and fall back to the + // initials chip in ProviderLogo. + "sonic-official": "sonic", + "celo-official": "celo", + "blast-official": "blast", + "taiko-official": "taiko", + "berachain-official": "berachain", + "zksync-official": "zksync", }; diff --git a/src/lib/spec.ts b/src/lib/spec.ts index c1facee4..90b95405 100644 --- a/src/lib/spec.ts +++ b/src/lib/spec.ts @@ -259,7 +259,9 @@ const loadBenchmarkUnfilteredCached = unstable_cache( // v19: ProviderResult gained `unresponsive` (cohort providers with // live call counters but no latency render as unranked badge rows). // Cached v18 entries would silently drop dead providers instead. - ["bench-unfiltered-v19"], + // v20: +12 long-tail RPC benches 055-066 (sonic…soneium). Bench SET + // changed; cached v19 entries would miss the new chains. + ["bench-unfiltered-v20"], { revalidate: 300, tags: ["benchmarks"] }, ); @@ -404,7 +406,9 @@ const loadAllBenchmarksCached = unstable_cache( // rpc-capabilities/ variant URLs and missing the 10 new // -rpc benches after deploy. // v23: bumped with bench-unfiltered-v19 (unresponsive provider rows). - ["all-benchmarks-v23"], + // v24: bumped with bench-unfiltered-v20 (+12 long-tail RPC benches + // 055-066; sitemap/citable/products must pick up the new slugs). + ["all-benchmarks-v24"], { revalidate: 300, tags: ["benchmarks"] }, ); export const loadAllBenchmarks = cache(loadAllBenchmarksCached); @@ -475,7 +479,9 @@ const loadBenchmarkFiltered = unstable_cache( // v10: bumped with bench-unfiltered-v18 (RPC per-chain cluster). // v11: bumped with bench-unfiltered-v19 (unresponsive provider rows; // region variants flag providers dead on that slice). - ["bench-filters-v11"], + // v12: bumped with bench-unfiltered-v20 (+12 long-tail RPC benches + // 055-066). + ["bench-filters-v12"], { revalidate: 300, tags: ["benchmarks"] } ); From e537e542a41081bf5f265c8489ecd2c2ad9ca374 Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Sat, 4 Jul 2026 13:51:42 +0200 Subject: [PATCH 083/144] logos: add 6 long-tail chain marks (gnosis, moonbeam, unichain, cronos, fraxtal, soneium) (#902) Co-authored-by: Florent Tapponnier --- public/logos/cronos.png | Bin 0 -> 18954 bytes public/logos/fraxtal.png | Bin 0 -> 6352 bytes public/logos/gnosis.png | Bin 0 -> 20950 bytes public/logos/moonbeam.png | Bin 0 -> 11607 bytes public/logos/soneium.png | Bin 0 -> 15457 bytes public/logos/unichain.png | Bin 0 -> 18132 bytes src/lib/logo-manifest.ts | 19 +++++++++++++++---- 7 files changed, 15 insertions(+), 4 deletions(-) create mode 100644 public/logos/cronos.png create mode 100644 public/logos/fraxtal.png create mode 100644 public/logos/gnosis.png create mode 100644 public/logos/moonbeam.png create mode 100644 public/logos/soneium.png create mode 100644 public/logos/unichain.png diff --git a/public/logos/cronos.png b/public/logos/cronos.png new file mode 100644 index 0000000000000000000000000000000000000000..9f5eddad721c9c0d02848a91c9ac952c0cab47fa GIT binary patch literal 18954 zcmV)ZK&!urP)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91fS>~a1ONa40RR91fB*mh07#AmcK`rD07*naRCodHomZ12$#I~&yQ*4i z-LpGtL0~}=0C9Fasz1+{Gdtb`2-_pnU@$mrPESZ92k zD{%_+`&U<2eM?5DBb08nT4NNCik9eGwlqy)a=1ptE3*0^1`4Cp86O|#D*XQS#pT82 z6_^Med<7}iR0SSD2_W$FD&Ywa>DZ)P}Fult!w+)*4KQdB-e!%g)R zBfX69C~iT9xnne&sK#pnnxLE4(twJ>)(u2b1Jo%2)#eCZM@LjHp@RIfO_7aE;#nj# zW>J~u5v8oaxeVAQWDVaKlhr>OXiNg1#- zj&?`U+n)%bo~wFh_OJS-KdHUz)`mtObcC4X1qN2C6g_DuUu#9@QTSL3Z==O46?VkQ z9UVnUn*~M_#V^?EzE&xWEc8O`w47=wZ=U zLo-oWua?M^H!^@G!U$G?z(62DapSF)*9h9kVn3lC0tp?HkzB9(S6IMo42o53N?>-Y z&KjX4H`e4IIB5x^v7iT;luuJ_MauO1mlqewDx{*_X}8*K5fyNCrCS{>2b z2<%C*@z4eo5TL|-H(*LwPpF1ElD9Xml;lIOiVP3NLQUF*beUmZT__6IDUtla7pf=q zqo?-Mma0}h7+k3kL;E8tH-))bz3!yQC0!Cl;y216e<3OK=M@f-RVQXQo0SJ~VMa;H zs}`dzlu*`54Pj}@k(3{9Gtxw;87DvMTXPU7LMyU4(;_llZ&3KXW9=AIrMS=vdn08G z)sfoJMk}#41uYsQTER!^aPQF!)TyLBza`C0+Sd0BXZ2PL_E08vD0*n`Rr4(g75_O6 z0K5r(R2&hDhw=f*P;s_}CF!GFQdBDu6${F9TXlAbz`$%`NnTZq7L<~T$h0Sd86sJ# zlzYGGI1w>@pU`e~${?sZTO$X;1(1ZXYk_FC%;f7zvzFnqGJV`iQf098i)dAf>daj< znPiWRw#LUh&>21e{G7SmrIR%Bm_N1Kt?^cyOcH1?CSSoGEYsy>|B|K)(XEnH(P~^@ zBQ#>bCz7jP$!jNbqN8gO9ipbV5Wh--MQM$U!ej>B^kN16uC6bc??qOW)ZS!IO*8&p zAsVepwo`ZKt%ysw2;5u;9?=sj_32x-Zl87kqKjex1?_VLr=_Kd_zJ1bEU!T>MdMb~D1W`zgs$OS%iC@$zl_UPPOM?M{WQD2dUN?rKP4%UaA?UAM1Wh6( zP>EcXv9cDVvdhV|tZ2si5`5J2c%wM=d>zh*#10stpU*#baS z85MKiYpB1!o!3DA19pRyUP2d!)T z>{r$E5^1K05@iEogK2g=lvPw7RjHwn)CMw1=R0y`AA*Ohkp((Z9AB0%VY&D-F&<+j z%9Y_JLDf{KF)CZfs{|Ak{>B_d0yfBv!Ck?W^eD_Fs#_IV5HG=)hjb;3M)w2YM_<~k zc~zA|VrZrgbz`I?zKJwygJSKN=0@Sb&NAFl$?v+aB2I9Kw8ogo#9~(DU!Gr(k)#AX zs-GpfEQ_FB^tuwl+Sjbh(FD1K)s+=K6BFZ>v_yW7^m^wzyW2Z^+n2Zmmtn6i6E7$~ zHa6aFji}V*MK;-D1eP(w2#c0PT22Eqd#XVSJ)A9}Ew78ov`D2q3kDzc1p}%cfUS+! z{7~meAF*OO68hQ%)Pk6|3YJ!PQd5Oc<<$)Zt0*NTf4gN-czxZ60ny?$Aln4lV{Z{- z?D6H*g{H-(;&QEVW~w_sH+SpS*7WoYsS!#Gr>7^Em*;!?d!yK5T?UM1uF*;<^`{!u z4v0XSGM=5B%Y-usa5dEuL@!EsgQLtUIZqQH=`>%Yo}QkLcRI7PGZ6jk?Cj|9=NvFGa`1e4r~9ifaMDXC`jL2ge2g@fp0N3Btb@dm|&yo`(2oF zR%V?x$8bC=qaX?u8UahB#VYB-9%FFWn_;Q@b#yLYxj3&l0G|L5o0Fx=&knlVh&M=(InM^Fr7L8~Hjh-M6hERdwjc=RCqk;wzjQ$46^ zO8i$yd6QMDNR*30^oG9Z4Px{lszjNbn4F%Po}HPUpIcjB+uGc8Oti+9R~D9+=f`6O z#kMO3plo!MS{+3AJ1iG=-K>z9c==jtW#KY0-jCbbe7nWC-*Vot3IcaZarY0xG$$^aeQMM6*wrQe9 zg{+BAo2{11i4IkkmzOYc)It$mT%P0SPEJlB-wXo6dAA48X&WC*LNXr`J-y(Rp}L6@ zkyk+;$`C=j1}1Qf7@erbeHMS;>tI6$h=!W}L@L=eIWh;Ay3>(V7fgyYO9!f1PhMA? zvl+-u8|0w|=VqpLa>hGogSq*+<(1|2wY90~DO5jUdV0z{CR0!=rUNx5H6lnc*_{qn ztv}HppPHFoSz3hp5M2gbzyK`D6zb?4ZPo1_kB(xWL;79IVxdh2NdFijuQmV?93d(1 zmUZ69ph?nyECWVcbGJ)dF%0D-At)*;B|xv_zV~Tm;eSQVFsJ6orZSVPm{3mFMs{(Gf@GSrC$AA0jr=P5@uFlNN%+Jp*FD-uZ>E_hbWOzaCrNzZKexeaU z)lw;}hfOBFM9?m*r(U6mVg_*DJKsClJ0%R%Vqke)Wm?M;|?56H|on3LpJ^(7eNvjifrZ0 zTdr;9?Ik-5WU=wD)JVAX6_or$F|Kvo0?5WdhEf4VJ;jV2L^m4k7TTH~#3thO)YQt_ z$}POhTU)sD^K-LH%L|{}+L)f2q$B}0Nq2kaJ0E4D)E!V_MR?uI$)Iy zxV|_iH>-Tm^{o@J0YpIGfG*9G1%X~8*RrKNydYNamyXV^$OdgZoY}cq^?Ii>zu;d6 zW>RyrGxJ{5{5LuSv=lWW1Ym_)E2SV_Z6S!@1!V@}IyXPtbLB(DjRKlKq`^f1Flk_{ z;Il$Lq?nWtf4G=fhgK@!2mPuN5)1B#)bY%$5v)5LS?}X|17uas?5d0r zbQASLMS|8O~=4A*3eFZFVE6^2l5H1E@A(JIo zSIcFuh$Bc62!rdM3X2FV^bn`p>np2kE2~bgTv3?4_t|H#0a{E=Pv5zX0a(H3p%?*f z0N~={$}Tw{mT}=j_L*>@$Ke5Z@dr2J+cFFrd{(hTl)^;}+;Og33v+Yh?e^lrJSKR3 zb$M%J{gchjGiJ=p(9X_&|K*p5Oc%O6aNpkD+uq$h50wIinMrdcfPgH9Mxump^s(hZ z^5Ptk98BUnL-Vd?O_f=Fv-MXPkb+W5K9zg z>6pM^SJAVe(;idR6P=l!`Si~1&wunI{2WAnX=(9iKm93sh5#?8W+t5ssa1pKh#Jjb zxXfxRtyszoW7j=B-P_wccPZPcC~jq}FI>Yp@SL8Rb{w<#Mkh2k5(Rwcu`F{lU@J{8 zE_>bc)6aj*EaBL2va+_iySqmWh4kgahs>FekL?Wxyw}rf&F-)$!YC5FHot#<(X|k5 zKpXvn7`RDCwL&ut-!*dFcdc%X#1g0CGf;jupctaMBjSw%J%V{QX-`8E!AF4sL33gk z1ey|%P$Yd}aghlY)L26sJDV1?6Rv%i&9-xH4ltg`jss8ND91R>F%^b}fFF#%;9aE2!iaJ6d?_y#CY?%Gc%+5HjIVy0j8#~4zV|HfA z+++@nsV~rq*n>zjm3a2AnHaUI3lp)-gSW_R{73KUe4t%&O^YV^mgr!FM^Aa>JwCB3 z1C}W8JnjG|@Gm#E@Fu5-)2rKeKHXSf!wk;MOrsGdn6|=&SjAF?D*6P<02MV_38Eif z#yThTc=qJk)2Gi+-(mt9sE6nSguA#pzwBS2$e?DYJw7|Pfc8^qu)OMEER!XAhJ^P~ z7}rd^aRMkb>n8rIm%%x(*8>)TY|OS~DpZ0jXT7uSm)kF1yf`{K zgb?3;`_03z9-@V!=gyh}#3)23f})23xLCc5E8HcN(ZuD%H7i1W&w)oHVoN1QI5rQb zhH=<-$%^(c4>Qv^2&+sOyG$46Up{&I3}lI)oSZO)rstNrzEU0OA7tx50{enKUrDW3 zBiX;^wP6EH&Wc0=)(wD#BS9X}(D|*a42p(-gm>?n{shy}=^4C3yiF+1IKaP@=7Fts zYN8XbLV+rmv%CFnk3E(yG6)002As%%0ECZKgTnw|1un0cT(t2KmF^UErM>Q2_N{SM zZFXqfF1fl)=`kE67>UEngt0Txf#@`i?{8X4Xz6Z@U5?>P?9Lo*{7CPQ6 z2sA$l)b@k(Hm^U5uAiPloS`^KudS}HEU%J+w=+MBUypbpXR6)Uk8j`{@VL-r_G3`z z&LG0{LF(h9Qw-n{{`ukI{{9}z`o~Adtkg4cmG^{1Cvgo{kp*YKfuW2JeDVCbT#IqQ z@&-l%+n|e}Wne}&j`UKSf4Y>EitCtF4ifMV)?)-tLwU+AHNfxR#{zp}c-oRc_8hTI~rfrE$#B@wROoo(og2;&H{T(iHo z&)8=(EBuM>*3q4s!tGGwAN!9kYgzyHbKVQ&xx#^3EbcYpMw z&uN{JwtsN&yWjnm;Yff7PDdCu%s{1Eh+E8^K#VZN8kv>JUYmj>G-J*T6nW56k%?j~XnW=cI$^OW zk18YH>%rh%3^GRh==k{2MPWhU1dgCX)SJt!&?{Q zhKfc{EB*v2HKfQiHVi2Xe`6N&>WE-2<2-r9l^vffO!~{rivESu(N9$n4hU`sBfZ;o zOa1KR?3fX8bcFZG-x)?gr$`0_nT9aS8)zgMVwnh{?7mvC>$E{^Ud_PTQfky4@MrfR@CZGqQB596HpXg=Cp9<1ffn_qGB5Ms%0~ z$*(MuFl!LP_d^2h;TT9-6_IOOh{*z4c1{K`xlQCKiNbOHGn5>)RhP_f0i%Q8@46?Y z?u626diHNwu*cV7cpvT`9v!eqsc`9)_D}-Uqq7?KeDqa$4UZ~h7rMYWW(ztqW{n)5 zoNAz2W26AuDIx3C{XR(JoTAMsrEs7~uO})}GVncumU$gnE6*jHGEPG{gYYs>)zcnK$M%KDpczG2_|U>|4yWN)AE{q8A7 z&cXzz&R7vbtCb7a2(n1=QGFPo4r@evd%FOK%Bu5dlcEm|d0V4vGSOfKUCOzxYoyJ|-8#`{KoO#2)IqL*gW;$6OSthdPMf($W&+ zjIP4*AOHBrpa1xCc2`6j_`nXu$k=)C0Bij_nCuypi}S;Sedku3&|;ml*ZunY$)GmV zG&bGl*4Ea+-v!j!+5h?T|FOKh9GbJezPZkaah-klOi%=*YHn_tX*12SsM778-rC%7 z6SOskv*5F8dT6~kZ(OPjzC?0x*NhE~05YO4Re`E)?~A5W5KMkvUTLp#aX2-%UmlAr?MOgL^EBQw_TV)J4m84ya3veYfr zi6kK-B1AH3;&_i~K}<6CN)`!K^->~1Hyfy`Hohz5rHZ<~i~ZD%V?u;(-`?J4u%dbP z(DL0gbd4g#qM;>po0BONweab7)0g4D0CZ?Kk1AdZiVXU}k;O?V@Lqe&DLYii6v z&^9M0GwcIvyCL(rre3X&qt3HzF@ouXzN|aJ98c+DIq+zRFqBy;UtC(Sq&5sp#x-aa zQ8N-Qj;->kaxxMLCcbZot)`*Ea+omSOWZ0HG1}Sa%Gr7(-@Mp9*u^3RGKhFRfBqaP zgGxt-2fz8vZ+`uoUqcmiJv?~&^r?e?r#s`WHOlW;`i=*&U@St=xDa_RqSurJnlgdCaUM1~ic& zl?-n5m`=#}I5NJrwsHUdeJG;X&(8e)PkzEY7JfiG%qE6Y=Z|(UdGJ;@;+nlMbkzFB z8tjCr(2;c5%!U`uAb6zBf)%>HvuPwvLqC!h-TuAyQYZB7q_}fe<5r?j0SUKxix* z40!td*|*<)=jzPqncWd(|JALN2%PRMULc1NmsXZ;eX_*~mGDw#e(-}IFyF=zVqj7h z6{TgO6+J=-9!h9*1nuFG_VBn~ZZGX@?;bkEKmd-=e((WW(<&@;cOhmBBiDMm%sPOQ zUggHd)+e9dW?bQBYfgr3GUyIIg&6Vmk@O$30ikz-Dw6Xi2S^$;ZGm?MN;4&L3j9 zWq~qK3zJ??ToF*v$_H|A|G{DCRRyoqu9*;#W_w}(keU>N*SD>N`Z#gSw&*Xr=N`Z1 zVv*T1>Bv9_|NR>t5?vFgVe){!e#0j~N8^x+Xh3feh{ zNJ$BVJxh!8i=*?fQ~#AI+k#6{00#!EH#jhiCkzr6=wSZ{MvnB@#>3I?JDUTXbKpE? z0!Y9Lx!JWsfQsy|kn3X-+L)3-J?Pw^xjs<(L9qfMDuvsD8$G9^4u75~vR+mV)evZt z*S|vcozddzqiDloQiq-(_N-E3RMF;4h3?$Fv$?sovcha=8ev~rga=y;3QPh4SC_Wk zpuM=joIZa1gfRr#(kbXq96nv%{sb!uz?eOBme*Eh&*x|wL(G8!6g$cglZ$y^qY>j^ z4>ZbGt@D)!TmY?wg{8^KzPl*nleh2O zPP(8^Ha53d{J}S6t?BXij~+aDz$}EZ>6sJOnmX+Iv!LU7ZRC-279_UpUkrjEw>?s+ zR#!%HMg1U6qVvJmn=|1z07&Ww3R0U7c=9PXZ>a?j^mOc|w67}4*_=ot;Ajcd#mdSm zivVaL^pYEk;}d8nAcvSs2&e&!(~~pi5J)j?)4sFALKM${!eJB;wt__h_TwB1W21~h zgngpJ_FR|!fRm$ROiIi%TAnx(dq#SEVi?=oDwz3d$bM(KctmI>R%!42Zp?&JgCw|+ z`=BonQn)j_1~@G7b#fHX+Fn(v7{d63Fc&=)LrXeGfD?K^252rp7+u%rJxV3}jt>vf zKa3;{Ia4{L7*lSWk_x68WhTXX#tMH^5X4)pbxlYz8DmB}sPVD$Y72@uQfW zQ`+21ko-uZ7dX&n&4=|CsPFNsa}S)L_g$HL`s{_X2R15u=i58m5K-4&!k`UdfI|T7 zv9rCyun7=X`uelaiXIBvDQvx5p^^q~6p_x3ny!4y2yVVK&00D7hSwfGBoh$OXa zWn|FtkBzl4JyqT4M)H_ONv>vp7T{L8dQFK|RwH;@BqK_(1RZN}tv&}s-8^H0i|%wm zIp*{Ni=|y2H`UFVo?()O+sOjw^z1D2Vf<7G^m1nxa=TQtxBu_|{_lu3=~zwl%t;*7 zfl4m$^}0C`rQ(-=c=*5n&ws#41_5(|&p-bgI6+|OJbwI$-7EYjb~*Ke_6}H?#iHma z;D$2Lu01#5q(WV%Yr+R>#erSkiPmx}NFsR56&moc7}TgK7nvV-85#jiUIRS2@EW*5 z?@IcE`VCaL&$Uow8ARC^a46u7TOp9Pu+BNMj{$&io}-#@MK1)_S?9&o#pwxj?jo(Y zJzswL2WD_i-)K4@@g)N;S!+7uN*qs##k}p^eI{!7LikZr)00l5XJ!~K-#>bc?qbVF z58r(^SE#W|8WsVCpwnT#_0r16oS;LJLiD5WpqP*s;+OT z26>u9d!F=IjvJ3MH~ z1vNI=5(mbMTcFvp`7Jo+sq}vDaOB{thhM4X*kj>*(UIDp%`!O}o{uvZ@CS3XjPvvS z2(tF2nR>J9=Fo`1K04pUO3outO{zq z9_zX*wg+NC|3R0fe11yvQL2tde?I8)79;!yIDuGRlI?On?%!77W{GA6;u#u=rI%HA zt6ozsiDw06E}D)~2_oxdyrNBlO&P9p>bB~LS==O8yO!`P6ow&JP1bl)k@$y)!Mmeh ztzM^OD?`8&!Rk7cmEEX%ubHSmH)O655E^AghAVE8(SqK`7D+c4;{46>l|A`#Acu zAAqf_!*Cb0HQ7+wfT~!c=oB+TMUIkP>=m8z5k1rc^IdEs&PeJ?`pw68rAW;PF;PIB zqbQ==OyyMQX}dWx$LFHoySzTXf5(ZQ zNnm(@>yRwalzU_;c6FS0iwS9o4+!V{gtHzjgS%+WNdev}<=Ix}>XZvX30=v7;`ng$ zaYJA*0+N0eqX%NqOo=jyYlW{^lWbx_$eTK;7Jnjx5?MOxM!yw&dX*zU2C2#w|B!r* zcNqbk9OrQ}9(Q&7e&lNOih~T!0;*9^<6wj)gEO{Pi9g9PjskyJ5e`KYGu+wAvN}CM z_HfU}@D4l{6}+lC61)w(lFy~BAr#(u^T9TtP`ZdqA>k1DZ?>74^|hH@ClYCd2+BuX8ku%bOLbL`O5r;wqzne|h#mu!!b%^oQY$EDcmu5xA1&Y1#1)z{uUc2 zsAuGkvvvT8NQ@FSkGRMbS~4KKZ63KJDC&+T)RZHNyhx)Mw@h^$T&1f~Glm{xE`l$p zab+t%2|7x}h5P2GWMop_C?63r;vN50qB+eCnSyOrh--DR4-v0xH(^wzZhnhN5+Eg( zUXT)6+DWUYZcybY6Hf4=i1p$*VipHoIghfmveKDg%dA80%);!{$HY96)=M0T3uj=?M2Ru){y>5e$Ka#u`^0{^l9FJMEFLr*$6fWApw3f4Fu(LDH6A4&JemSVaHvXMEx7{0V?<2M#TP06GRklqpEaf zIMo*Gt%x`S0ksCp*#TCJp{~Nif67e~Ho=>ofkKE|5x>a%I671e3lPJX0Rx89JasuPt~iT01STu--6XoPt09RWY*VLSPf;9hZQBa zA$>H1Ey)uew*JE(zT{Cmp2!A05bR&_02B+Z%t>+hV^I(j&9gBv$B={(q=Y1KhU&+q zx$@D7mz7~ksNdjhEVM!z0ARiWp^Y&ysGBfjVwX{`4!1Xkn#=+0jm4T7U>wYpp|Z3N zNH)E5ncCGh0@9B>(mzlP6$Bd?o#<}-QQjZdn?HvEY|(6QzkD{&(;0Rd(nH{606K?b zMLeL1?veo$42Y%JVr$U?!(wWR9kU)*nWul@7W;2Z&6Rf-CAf-dd}^u^iSz;1k-%+s zC9ievjYzX9IB-)OtK6+nz>hjpmaXk9JDxL5Mn zdu;JW!#FzboL-%P1z`Ia65^(gPf(5n!MKv~^3}604^X1rDGClcOz}7-aUKrfDHj;P z8D1<0`(}%abDG}P7#=6a{1kVn%j3QiM+XNoz%6QCzCmDA+NtPnU~FRHM@ekNxDcs;YO$d)KzY|F8h9`am|XYZEkkjXbHbv%QVra%9IT7*E2?`2bhaf%4Sm=z1SZJ3YO) zu%PJ*;FO^W;?NX3f$@kV?t0MZER-7+X=rzEpQ9*jGGhQZCX5Ed*JK2ufY^(0WiMVW9G`jf3& z`0(y%@emzPelsa+aoD7N@9rJaI6|A9XRss*5N$LS=PLk63ft~a9zECRk2e`Ve*Bo` zI9AG$i0$nip7P}uo9w9|H^`h2fHXXXg&cI}OfYi5NWycK%o+Gj-<&iTZuO2Sz%|@t znG*C0NTp64ud_%=Qdwd!0y4E457DNYQNlaeq<{RN2AYKRDb@g0-)e8wcXB|0hD6c^ zT{wfp9SU#-OPdY@MP0VY9TsDwJS%~$@jeu!n8z_VrLw-Uj(x_bj3?cvd5&m3eMADnB}Mp$-R)B!8(*paamT-&Yq`N{F%U7=k#7D{dkw2M7D8 zAUyZggEh{P^BhKZp6l%8oVEdFz)-kuUx#-;O{4a=dYxRvojowuqEU1Xl;u2KJnZd- z_sno+i+35&D3%6?QY%OJ;W$XV6Q3?ifV5%0sx-s7m^ zp2J6;B7i8Uthoc;@p&L~36S#mhzHi0UPbrdC}>A_@s~QK>wv`$1`IF=IRmf;cj zg2{t0jD^@&%qJ53D2lBv2+^Sw?=j@9^yn%q+}75ud-v{n$nfM8|DM?pdIlZlDCYgM zm}Oq;I?9nOZ1Voo=T9LSmX$}87MGW>qF83yoP~$evz*=qH;`2lqe(O(ghsId2P&h` zzJL6P+k8X`fG0;jg&B`1X=#xLc*dY1&@(eL@o4-Q(_{(2VN1VDZ3HYzBZr3!LxY1t81&Fh&l{eEI+n@ z))8#>gt^w8B9Yz|0)rv)lEES$69y`X+Jb^w!2o+DP#cLz)j~9?8|uSfeh@6J8We5w zHh9nJ>lPOr2Lj0g)hCByO!PBltj~Azm?g z=gwV+4v)TgeEfT!Z{hejehY6@!Ds*d@4tj4*1aC#Xg}Z4R*Oe1e)X$gGjxDXz*Gj) zeGZ8wPn@67$(|*}hh(LQC?tUoC&*c6aSeqR4a8gfEBKh=5>*(H z!Z=PsQ&-y5S*=%}XD&`411gw@F(5iXu%SFL&^q(|O0xXS*J=ZZZXkVh8o}lc;&;Y; z5R5o$?I&>$&aS(%mGE}fel#e>ra|8r+Bjx4@sLKvT-~}N8 zWt>|g(BFA*xQr|YjG}E>V#M2K_#@i%f;Aha@#sY=>ng>ImJ$%;gGT|Gmqqhd4%jbZ zXld3m(cJ)ssQ5!XW6e|AY?Zm2z>iRc8DR8LH}n7xh)EkXe5ZBc3P<#i!m>Hu^o^>4 z!>AM}3n;uMrqFzN29~yb1RSS_m)u#w_i4fjkfZ@T5B8g*t9r)}U3EO-Z&i9)`|t>a zm~aq+0jY(;5gMj!cJrOrAtU^*(qQN(oOC6ZK%ddnOz%&RPni-i1Wu z4xt7RBTW6m8j-(G5buG&2f(e0C(^XZ5mLfyKq(X4DrdZ!ns+cobG&B@H-W*51o*VS zuX=GF2?_WFj}k^;^xhXOepOgqRcSn7fkA#L_uMk*gM#eH_L$0_oX`i%64?CT|M(9C zEWC);=GMm6)&|pgWDl$GnZLoNB+_43x7g==>W9n#(0T;IBN zYjg7!{Z+;FdQTsH&mtNUM{_S;Zu3W?SYe;p+>9Q;KVNV#KMz*ec1F+-fA~W^8~>-6 zYOO3U^6LUl?!x(*ndNQ9c;V1r5O&Z$JHk-+^c=-_%c=&1Y!bo)N{jlEkM);Ls)(`G{dK;_D+zKD} zU;pKwgCjmG$qxfL27EaJI?9j>ZFbKk1x$z$`pb*DkZfoQk6-TW|LVVfjn_*YY53;b z@A#;7c@50P1%6kIP>fT|PfN#SK;rg3*I^Z8-8WUP(ql9*C`bH>IoxorqqvPQht)~~ zw8ph(X76hL_(Z%R?~0RG6i)M4qg6MBsccPfkk;iL$~f;D=lyOIc-fBnlM~+Or}=1g zc(xAb9T~5INVW(h;b?|qGtQ0zn>JWo-h9IwWg)t6qVaV^gPDKUN(R(QKW@=HevoKi zx8n}}J_8dpaDSQTxn9U>Yb?kspnNp-?77wAJHYOpvEieM4C#u_QL}ktGXro8*}U@_ zy1*+zyWlA1wfo@7yM)<+<=3Ko{T|*8(=^=@q>#ZDr`QHSYv}d^L~^yyMW<84ku<9AU@LrF{9LuIM8APIpvGR~GR_r>Mo zM^7K~wv<>y4M`9SDnRU8w>B`|_&suwhnKAfF}!4m$wczNYY@@zlHK@v zWdsN<0w{xJ#eiTnWCsKkhvt+^aH6Aw$ZQ;kdCb?>l8@pb9~QEv&y_fO88)!AiGxU- z;uunx(f4(c;bKw9Kikzv=1;x=loz*-PtXE-0#!yPM#7~CrGO?AV_%oPwY9dnwZ@o& z=(NQ(_{zk^r`^FXmj$u?@os3HC@_bqh&}Mlv(v zj+^$upS-QrT4vdV4Gc&_j1OsGEe{ZFm$R^0J*M&&p&8Yz(BQ z$)HE%R~K;5FNpU=sWO?{v2b(Yjy#iF$i4HDpAO@!iqFx55&{H`3NWhmZHgI>PjkyzL_Bp>5Ux^jh6z@pvbCr?;RWK44T z`m3*$jBtK<+XP$o7-0bGTflHd%Emn}mTRTqhsdKK3K&+Jkm#!dP)nhGF;rAG+FAdb zNUJWH7mfFMah*aCq?ge_2Ktv;c6^eh$dpWc10z8TC+>lo^ydfkI7|Y}U;|x{I;>6P z4=inNq_t!Q`;(vi{hd!gU0+{^h-`<7T2P(m~k z&u7p?)tBlU#mD01&K?^EZV|H?_w6^|eDTE>yon{YGA?%feFA?EiyDsJ)FyO?(PCdp z$2CmVDlZNzUK2bbk6JkstEsOrme!ltRPhFiLq@nlxO@i!(YEk15^63Q4XWBSnYF*_ zy-%K#SP-CI20Oz-PTcd{3Ao^0FjW{o_rMk_5aHyYjl&tF+Z6<=XX6P6e&XHN46yl% z0Rgo%H}n;50^DRIE}#G&eL&f9U zKcva@6rQm+e6V=HDCV{yI3WXm}4ZXp5;=E}jxQPG#qbF>iKyjw!?EG*p5eElmBlT!l>Fp0SsOW?A6;S1y zP%esP{Fy|b>}j$g%IBI?U}H{1-g#-B4Go0`O$`9%P3;|7UWE#&Yz)d>vJ`3mMLK>A zC=e{`T8TL?gcn5%0mjbp^V0zkg>bNcXop8f_jeEWUhZlS;z#ge%W&xvR`jN)m@%+` z|4;w)Ppd1KgxGtToosg|^h<;oi@AXjo(sm4IW1Zn%Ln42p^J zP-u(*$rmKd0x{r{z9dAoteZlRXO#+dn+3A%(ztxg%(~qIgq9fm0Hz7#l!NN0C>P)0 z?TE$bV**lGZR}Vx0>pK33vo1RT`*JpV3y}!P2c{$EQ zto|TOlM2}mSI}a;0QZp^7qdNFX{?V!Mu%l73Wf!4d`Z3%P=ypqNhpQGSthNN(xAx~)r(S&(xBtIp-Bb+Vbfz) zV4)xaCjiv1+CxYMklv;D1OuQjlw)N$*q+igz6pq-yfCl|)4lrp}2}cFo ze{=>vCJx71?Nc_JP$H;pmbKB{KtqYMxJ_ADALrhzh=Kj~+wUGedHm?~dM5U-gve2ryrJ}dxS{G_of1miU4QPTX9C!0w z5}izcV^a_~)qa$tTdE`Hd*0(xJGzMnEU3r&g!WPb>uysRRlEBF$rA4RhH z{EB#@(;##iEs1!s43y7UN|169l?T;)6y6Y7M2o0pV&v49a53t;W@Oj~G;{vsaZ!|H zQbdvVv6>7trT)P{C-N4PAwgG)&@&(0$iUukOok-{g>v_?LN?j}R4mJ+$Tuk$;Ioaa zK|Be1BavKEOjJ!;wl0v7tYyX@8UBI{2uzE3rKSmX6t2+Ka9y)M&>D$`0WooKh!D(D zuu(5Wk}UG-wZx!E(O&}^mpX|^Hk3?~{dw1;}=2_h(L)VRZ!BsOo=H1MV{H1-SDLASjv8%5AMsCnTZPJ>wa<5EOfW)lCLKs+KQ;^N&zRlJMaf6+ECp z-E@@ZPIA_Xb)-f-Q<`P!tc<)`V;aw)FKv2wl91_iQArZ?j^O1|ax<7y27{L3<6sOcbR&46Kbp5(kyd~jQL zT%*#E@CH$blLuA*yTyhWQ1=TgA;F)h4;55Ix}k#5JuO!koiw32w5&crs@h=yxK~ho z7K`AOhDr%g5equ1aL5S#t+faf5G;*LT9XD--bj1_4Z_gPAoLcFS2F&vb3B5}o&P85|}F16KDDridDFlBAw4Ix^8 zLtdTy=itmu2GouHxgs@5q)@2rBt7)2vLMDAyIK<}Bf!8m47o6wQDy(Ry74E2TYSVn p8N9y&`alZ&l>r|}us?P2{|_9V`zY%E=>-4)002ovPDHLkV1hdx#d`n% literal 0 HcmV?d00001 diff --git a/public/logos/fraxtal.png b/public/logos/fraxtal.png new file mode 100644 index 0000000000000000000000000000000000000000..6dfd1b030a1b5968e227a333adef195147166f54 GIT binary patch literal 6352 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 literal 0 HcmV?d00001 diff --git a/public/logos/moonbeam.png b/public/logos/moonbeam.png new file mode 100644 index 0000000000000000000000000000000000000000..2a403502588a5a27bc3f3d94ce05cf03b0b149f3 GIT binary patch literal 11607 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 HF!=ug2k#r} literal 0 HcmV?d00001 diff --git a/public/logos/unichain.png b/public/logos/unichain.png new file mode 100644 index 0000000000000000000000000000000000000000..ba31841253034ced691cf312736f871f06ae2d50 GIT binary patch literal 18132 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

^2oAbU1y$HTBDeNyx%2g4yS*>`L&G&9% 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 = { // ─── L2 chains (additions) ─── taiko: "/logos/taiko.png", + // ─── Long-tail RPC cluster chains (benches 055-066) ─── + gnosis: "/logos/gnosis.png", + moonbeam: "/logos/moonbeam.png", + unichain: "/logos/unichain.png", + cronos: "/logos/cronos.png", + fraxtal: "/logos/fraxtal.png", + soneium: "/logos/soneium.png", + // ─── Solana transaction landing services (bench 016) ─── jito: "/logos/jito.svg", nozomi: "/logos/nozomi.svg", @@ -328,16 +336,19 @@ const ALIASES: Record = { "arbitrum-official": "arbitrum", "avalanche-official": "avalanche", "optimism-official": "optimism", - // Long-tail RPC cluster (055-066). Only chains with a registered - // brand mark are aliased; gnosis / moonbeam / unichain / cronos / - // fraxtal / soneium have no logo file yet and fall back to the - // initials chip in ProviderLogo. + // Long-tail RPC cluster (055-066). "sonic-official": "sonic", "celo-official": "celo", "blast-official": "blast", "taiko-official": "taiko", "berachain-official": "berachain", "zksync-official": "zksync", + "gnosis-official": "gnosis", + "moonbeam-official": "moonbeam", + "unichain-official": "unichain", + "cronos-official": "cronos", + "fraxtal-official": "fraxtal", + "soneium-official": "soneium", }; From c2c8b14adab86a60b9424bf82bb80d8e023164a1 Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Sat, 4 Jul 2026 14:02:37 +0200 Subject: [PATCH 084/144] rpc hub: reliability columns, chain logo headers, per-chain RPC table on product pages (#904) Co-authored-by: Florent Tapponnier --- scripts/dry-run-rpc-hub.ts | 29 ++- src/app/products/[slug]/page.tsx | 6 + .../rpc-provider-chains-section.tsx | 188 ++++++++++++++++++ src/components/rpc-providers-pivot.tsx | 85 +++++++- src/lib/rpc-hub-stats.ts | 79 +++++++- 5 files changed, 368 insertions(+), 19 deletions(-) create mode 100644 src/components/rpc-provider-chains-section.tsx diff --git a/scripts/dry-run-rpc-hub.ts b/scripts/dry-run-rpc-hub.ts index 8204ea7b..bc1d5ec0 100644 --- a/scripts/dry-run-rpc-hub.ts +++ b/scripts/dry-run-rpc-hub.ts @@ -69,9 +69,36 @@ async function main() { console.log("\nPIVOT (top 10 by coverage):"); for (const r of snap.providersPivot.slice(0, 10)) { console.log( - ` ${r.provider.padEnd(14)} chains=${String(r.chainsCovered).padStart(2)}/${snap.totals.chains} medianRank=#${r.medianRank} medianP50=${r.medianP50Ms} ms`, + ` ${r.provider.padEnd(14)} chains=${String(r.chainsCovered).padStart(2)}/${snap.totals.chains} medianRank=#${r.medianRank} medianP50=${r.medianP50Ms} ms success=${r.medianSuccessPct != null ? `${r.medianSuccessPct.toFixed(2)}%` : "—"} errors24h=${r.errors24h?.toLocaleString("en-US") ?? "—"}`, ); } + + // Product-page extract: the per-chain table /products/ renders + // (rank among live rows, p50, success, derived error count). Override + // the provider with `--product=`. + const productArg = process.argv.find((a) => a.startsWith("--product=")); + const product = productArg?.slice("--product=".length) ?? "drpc"; + console.log(`\nPRODUCT TABLE · /products/${product}:`); + for (const c of snap.chains) { + const idx = c.providers.findIndex((p) => p.provider === product); + if (idx >= 0) { + const p = c.providers[idx]; + const errors = + p.sampleSize != null && p.successPct != null + ? Math.round(p.sampleSize * (1 - p.successPct / 100)) + : null; + console.log( + ` ${c.name.padEnd(14)} rank=#${idx + 1}/${c.providers.length} p50=${String(p.p50Ms).padStart(7)} ms success=${p.successPct != null ? `${p.successPct.toFixed(2)}%` : "—"} errors24h=${errors?.toLocaleString("en-US") ?? "—"}`, + ); + continue; + } + const dead = c.unresponsive?.find((u) => u.provider === product); + if (dead) { + console.log( + ` ${c.name.padEnd(14)} UNRESPONSIVE p50=— success=${dead.successPct != null ? `${dead.successPct.toFixed(2)}%` : "—"} n=${dead.sampleSize ?? "—"}`, + ); + } + } console.log(""); } diff --git a/src/app/products/[slug]/page.tsx b/src/app/products/[slug]/page.tsx index 3ad3525d..c51e8c60 100644 --- a/src/app/products/[slug]/page.tsx +++ b/src/app/products/[slug]/page.tsx @@ -29,6 +29,7 @@ import { } from "@/lib/perp-venue-context"; import { PerpVenueSection } from "@/components/perp-venue-section"; import { PmDataFeedSection } from "@/components/pm-data-feed-section"; +import { RpcProviderChainsSection } from "@/components/rpc-provider-chains-section"; export const revalidate = 60; @@ -682,6 +683,11 @@ export default async function ProviderPage({ + {/* Per-chain RPC deep-dive from the rpc-hub cohort snapshot. + Renders nothing for providers outside the free-RPC cluster + (the section fetches the cached snapshot and self-filters). */} + + {badgeCards.length > 0 && ( diff --git a/src/components/rpc-provider-chains-section.tsx b/src/components/rpc-provider-chains-section.tsx new file mode 100644 index 00000000..3aea7c89 --- /dev/null +++ b/src/components/rpc-provider-chains-section.tsx @@ -0,0 +1,188 @@ +import Link from "next/link"; +import { fetchRpcHub } from "@/lib/rpc-hub-stats"; +import { ProviderLogo } from "@/components/provider-logo"; + +/** + * "RPC performance by chain" section for /products/. Renders only + * for providers that appear in at least one chain of the rpc-hub cohort + * snapshot (dRPC, PublicNode, Tenderly, 1RPC, ...): one row per covered + * chain with rank, 24h p50 (3-region aggregate), success rate and the + * derived failed-probe count — the same figures the /rpc pivot shows, + * scoped to one provider. + * + * Server component, snapshot-only (fetchRpcHub reads the worker-written + * cohort blob; zero Prometheus traffic). Rank is the row's index in the + * chain's `providers[]` field, which rpc-hub-stats sorts fastest-first + * over LIVE rows only — unresponsive providers are unranked there, same + * convention as the bench-page ledger, and render here with a dashed + * latency plus their still-recording success rate. Returns null when + * the provider is absent from the snapshot, so non-RPC product pages + * pay one cached read and render nothing. + */ + +type Row = { + chain: string; + chainName: string; + benchSlug: string; + rank: number | null; + totalRanked: number; + p50Ms: number | null; + successPct?: number; + sampleSize?: number; +}; + +function errorCount(row: Row): number | null { + if (row.sampleSize == null || row.successPct == null) return null; + return Math.round(row.sampleSize * (1 - row.successPct / 100)); +} + +function fmtMs(v: number): string { + if (v < 1000) return `${Math.round(v)} ms`; + return `${(v / 1000).toFixed(2)} s`; +} + +export async function RpcProviderChainsSection({ + providerSlug, + providerName, +}: { + providerSlug: string; + providerName: string; +}) { + const snapshot = await fetchRpcHub(); + if (!snapshot) return null; + + const rows: Row[] = []; + for (const c of snapshot.chains) { + const idx = c.providers.findIndex((p) => p.provider === providerSlug); + if (idx >= 0) { + const p = c.providers[idx]; + rows.push({ + chain: c.chain, + chainName: c.name, + benchSlug: c.slug, + rank: idx + 1, + totalRanked: c.providers.length, + p50Ms: p.p50Ms, + successPct: p.successPct, + sampleSize: p.sampleSize, + }); + continue; + } + const dead = c.unresponsive?.find((u) => u.provider === providerSlug); + if (dead) { + rows.push({ + chain: c.chain, + chainName: c.name, + benchSlug: c.slug, + rank: null, + totalRanked: c.providers.length, + p50Ms: null, + successPct: dead.successPct, + sampleSize: dead.sampleSize, + }); + } + } + if (rows.length === 0) return null; + + return ( +

+

+ RPC performance by chain +

+

+ Where {providerName}'s free endpoint ranks on each measured + chain: 24h p50 across 3 probe regions, success rate and failed + probes. Full field on{" "} + + /rpc + + . +

+
+ + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + ))} + +
ChainRankp50 (24h)SuccessErrors (24h)
+ + + + {r.chainName} + + + + {r.rank != null ? ( + <> + + #{r.rank} + + /{r.totalRanked} + + ) : ( + + unresponsive + + )} + + {r.p50Ms != null ? ( + fmtMs(r.p50Ms) + ) : ( + + )} + + {r.successPct != null ? `${r.successPct.toFixed(2)}%` : "—"} + + {errorCount(r)?.toLocaleString("en-US") ?? "—"} +
+
+

+ Rank counts live providers only; unresponsive endpoints keep + recording success rate but hold no latency percentile. Errors + (24h) = sample size × (1 − success rate). +

+
+ ); +} + +function Th({ + children, + className, +}: { + children: React.ReactNode; + className: string; +}) { + return ( + + {children} + + ); +} diff --git a/src/components/rpc-providers-pivot.tsx b/src/components/rpc-providers-pivot.tsx index 843b445a..883dc051 100644 --- a/src/components/rpc-providers-pivot.tsx +++ b/src/components/rpc-providers-pivot.tsx @@ -21,7 +21,19 @@ import type { RpcHubPivotRow } from "@/lib/rpc-hub-stats"; type ChainRef = { chain: string; name: string; slug: string }; -type SortKey = "chainsCovered" | "medianRank" | "medianP50Ms"; +type SortKey = + | "chainsCovered" + | "medianRank" + | "medianP50Ms" + | "medianSuccessPct" + | "errors24h"; + +/** Optional reliability fields (absent on old snapshots) resolve to + * null so the comparator can pin them to the bottom either way. */ +function sortValue(r: RpcHubPivotRow, k: SortKey): number | null { + const v = r[k]; + return v ?? null; +} export function RpcProvidersPivot({ rows, @@ -46,8 +58,17 @@ export function RpcProvidersPivot({ : rows; const factor = sortDir === "desc" ? -1 : 1; return [...out].sort((a, b) => { - const av = a[sortKey]; - const bv = b[sortKey]; + const av = sortValue(a, sortKey); + const bv = sortValue(b, sortKey); + // Rows without the metric (old snapshot, no sampleSize) sink to + // the bottom regardless of direction. + if (av == null && bv == null) { + return ( + b.chainsCovered - a.chainsCovered || a.medianRank - b.medianRank + ); + } + if (av == null) return 1; + if (bv == null) return -1; if (av === bv) { // Stable tie-breaks: coverage, then rank quality. return ( @@ -62,8 +83,11 @@ export function RpcProvidersPivot({ if (k === sortKey) setSortDir((d) => (d === "desc" ? "asc" : "desc")); else { setSortKey(k); - // Coverage reads best descending; rank + latency ascending. - setSortDir(k === "chainsCovered" ? "desc" : "asc"); + // Coverage + success read best descending; rank, latency and + // error counts ascending. + setSortDir( + k === "chainsCovered" || k === "medianSuccessPct" ? "desc" : "asc", + ); } }; @@ -113,7 +137,37 @@ export function RpcProvidersPivot({ > Median p50
- Per-chain rank + setSort("medianSuccessPct")} + > + Success + + setSort("errors24h")} + > + Errors (24h) + + {/* Chain logos double as column headers for the rank + strip below: same 22px slots + gap as the cells, so + each rank square reads under its chain mark. */} + + Per-chain rank + + {chains.map((c) => ( + + + + ))} + + @@ -169,6 +223,16 @@ export function RpcProvidersPivot({ #{fmtRank(r.medianRank)} {fmtMs(r.medianP50Ms)} + + {r.medianSuccessPct != null + ? `${r.medianSuccessPct.toFixed(2)}%` + : "—"} + + + {r.errors24h != null + ? r.errors24h.toLocaleString("en-US") + : "—"} + {chains.map((c) => { @@ -199,7 +263,7 @@ export function RpcProvidersPivot({ {filtered.length === 0 && ( No provider matches “{q}”. @@ -211,9 +275,10 @@ export function RpcProvidersPivot({

- Per-chain cells show the provider's leaderboard rank on that - chain (24h p50, all regions). Cell order:{" "} - {chains.map((c) => c.name).join(", ")}. + Per-chain cells show the provider's leaderboard rank on the + chain marked by the logo above (24h p50, all regions). Success is + the median 24h success rate across covered chains; Errors (24h) is + the summed failed-probe count (sample size × failure rate).

); diff --git a/src/lib/rpc-hub-stats.ts b/src/lib/rpc-hub-stats.ts index 88dd3856..8936a86f 100644 --- a/src/lib/rpc-hub-stats.ts +++ b/src/lib/rpc-hub-stats.ts @@ -58,6 +58,15 @@ export type RpcHubProvider = { regions: Partial>; }; +export type RpcHubUnresponsiveProvider = { + provider: string; + name: string; + /** Success rate over 24h (%, 2 decimals). Call counters keep + * recording through an outage, so this stays meaningful. */ + successPct?: number; + sampleSize?: number; +}; + export type RpcHubChain = { /** Chain slug, e.g. "ethereum". */ chain: string; @@ -72,6 +81,9 @@ export type RpcHubChain = { * (probed, all calls failing, no latency). Never part of * best/fastest computations — display-only context. */ unresponsiveCount?: number; + /** The unresponsive rows themselves (never ranked). Additive optional + * field: old blobs without it still parse. */ + unresponsive?: RpcHubUnresponsiveProvider[]; best: RpcRegionBest | null; /** Best provider per probe region. */ regions: Partial>; @@ -89,7 +101,17 @@ export type RpcHubPivotRow = { chainsCovered: number; medianRank: number; medianP50Ms: number; - chains: Record; + /** Median 24h success rate across covered chains (%, 2 decimals). + * Optional: absent from older blobs and success-less rows. */ + medianSuccessPct?: number; + /** Failed probes over 24h summed across covered chains: + * Σ round(sampleSize × (1 − successPct/100)). Optional (needs + * sampleSize; absent from older blobs). */ + errors24h?: number; + chains: Record< + string, + { p50Ms: number; rank: number; successPct?: number; sampleSize?: number } + >; }; export type RpcHubSnapshot = { @@ -105,6 +127,7 @@ export type RpcHubSnapshot = { const RPC_HUB_KEY = "rpc-hub"; const round1 = (v: number) => Math.round(v * 10) / 10; +const round2 = (v: number) => Math.round(v * 100) / 100; function median(values: number[]): number { const sorted = [...values].sort((a, b) => a - b); @@ -201,8 +224,9 @@ async function buildChain(spec: Spec): Promise { const leaderSeries = bench.extras.series24h?.[leader.slug]; // Unresponsive rows are excluded from `rows` by liveRows (they carry // availability="unavailable" and zero latency), so they can't touch - // best/fastest — surface only their count for the chains table. - const unresponsiveCount = bench.results.filter((r) => r.unresponsive).length; + // best/fastest — surface count + identity/success for display-only + // consumers (chains table, product pages). + const unresponsiveRows = bench.results.filter((r) => r.unresponsive); return { chain, @@ -210,7 +234,21 @@ async function buildChain(spec: Spec): Promise { name: chainLabelForSlug(chain) ?? chain, benchTitle: spec.title, providerCount: rows.length, - ...(unresponsiveCount > 0 ? { unresponsiveCount } : {}), + ...(unresponsiveRows.length > 0 + ? { + unresponsiveCount: unresponsiveRows.length, + unresponsive: unresponsiveRows.map((r) => ({ + provider: r.slug, + name: r.name, + ...(Number.isFinite(r.successRate) + ? { successPct: round2(r.successRate) } + : {}), + ...(r.sampleSize != null + ? { sampleSize: Math.round(r.sampleSize) } + : {}), + })), + } + : {}), best: { provider: leader.slug, providerName: leader.name, p50Ms: round1(leader.ms.p50) }, regions, providers: rows.map((r) => ({ @@ -220,7 +258,7 @@ async function buildChain(spec: Spec): Promise { p99Ms: Number.isFinite(r.ms.p99) && r.ms.p99 > 0 ? round1(r.ms.p99) : undefined, successPct: Number.isFinite(r.successRate) && r.successRate > 0 - ? round1(r.successRate) + ? round2(r.successRate) : undefined, sampleSize: r.sampleSize != null ? Math.round(r.sampleSize) : undefined, regions: regionP50[r.slug] ?? {}, @@ -237,25 +275,48 @@ async function buildChain(spec: Spec): Promise { function buildPivot(chains: RpcHubChain[]): RpcHubPivotRow[] { const acc = new Map< string, - { name: string; chains: Record } + { name: string; chains: RpcHubPivotRow["chains"] } >(); for (const c of chains) { c.providers.forEach((p, i) => { const entry = acc.get(p.provider) ?? { name: p.name, chains: {} }; - entry.chains[c.chain] = { p50Ms: p.p50Ms, rank: i + 1 }; + entry.chains[c.chain] = { + p50Ms: p.p50Ms, + rank: i + 1, + ...(p.successPct != null ? { successPct: p.successPct } : {}), + ...(p.sampleSize != null ? { sampleSize: p.sampleSize } : {}), + }; acc.set(p.provider, entry); }); } const rows: RpcHubPivotRow[] = [...acc.entries()].map( ([provider, { name, chains: perChain }]) => { const cells = Object.values(perChain); + // Reliability aggregates over covered chains only. Success is a + // median (robust to one bad chain); errors are an absolute sum of + // failed probes, same derivation as the ledger's Errors column. + const successes = cells + .map((c) => c.successPct) + .filter((v): v is number => v != null); + const errorCells = cells.filter( + (c) => c.sampleSize != null && c.successPct != null, + ); + const errors24h = errorCells.reduce( + (sum, c) => + sum + Math.round((c.sampleSize as number) * (1 - (c.successPct as number) / 100)), + 0, + ); return { provider, name, chainsCovered: cells.length, medianRank: round1(median(cells.map((c) => c.rank))), medianP50Ms: round1(median(cells.map((c) => c.p50Ms))), + ...(successes.length > 0 + ? { medianSuccessPct: round2(median(successes)) } + : {}), + ...(errorCells.length > 0 ? { errors24h } : {}), chains: perChain, }; }, @@ -340,8 +401,10 @@ async function fetchRpcHubRaw(): Promise { const fetchRpcHubCached = unstable_cache( fetchRpcHubRaw, + // v3: pivot rows gained medianSuccessPct/errors24h + per-chain + // successPct/sampleSize; chains gained unresponsive[] rows. // v2: chains gained unresponsiveCount (unresponsive provider rows). - ["rpc-hub-cohort-v2"], + ["rpc-hub-cohort-v3"], { revalidate: 60, tags: ["rpc-cohort"] }, ); From 26c9db4cd85631a40b4addf46da8b2671af20b37 Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Sat, 4 Jul 2026 15:56:15 +0200 Subject: [PATCH 085/144] fix(spec): memoize spec file reads to stop EMFILE collapse of the catalog aggregate (#906) Co-authored-by: Florent Tapponnier --- src/lib/materialize/load.ts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/lib/materialize/load.ts b/src/lib/materialize/load.ts index ad5d52f5..03fe0cdd 100644 --- a/src/lib/materialize/load.ts +++ b/src/lib/materialize/load.ts @@ -65,7 +65,27 @@ export function parseFilterSig(sig: string): BenchmarkFilters { } return out; } -export async function loadSpecsUncached(): Promise { +// Module-level memo. Spec YAMLs are immutable for the lifetime of a +// deployment (Vercel lambda) or worker container, but the React cache() +// wrapper around this loader is a no-op inside unstable_cache callbacks +// — so the catalog aggregate was re-reading all spec files once PER +// BENCH, in parallel. At 57 specs that is ~57×57 concurrent opens and +// the lambda dies with EMFILE ("too many open files"), collapsing +// /api/citable and the sitemap to drafts (2026-07-04 incident, first +// triggered by growing the catalog from 45 to 57 specs). +let specsMemo: Promise | null = null; + +export function loadSpecsUncached(): Promise { + specsMemo ??= loadSpecsFromDisk().catch((err) => { + // Never memoize a failure: a transient fs error would otherwise + // poison every subsequent load for the lambda's lifetime. + specsMemo = null; + throw err; + }); + return specsMemo; +} + +async function loadSpecsFromDisk(): Promise { let files: string[] = []; try { files = (await fs.readdir(SPECS_DIR)).filter( From 83274390306124dc0437796678a1ac68fec3874e Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Sun, 5 Jul 2026 12:48:57 +0200 Subject: [PATCH 086/144] seo: purge thin compare pages + benchmark-first title on products & compare (#908) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * seo: purge thin compare pages + benchmark-first title on products & compare Bing WMT audit 2026-07-05: only 2 of 5093 sitemap URLs indexed. Root cause: 4938 ad-hoc /compare/ pages emitted at ≥1 shared bench threshold, producing near-duplicate templates that starved crawl budget and hurt domain trust. Changes: - sitemap: hybrid threshold. Brand-whitelist pairs emit at ≥1 shared, others at ≥3. Drops from 4938 → ~226 ad-hoc + 21 curated = ~247 URLs. Preserves every commercial X vs Y pair users actually search for (helius-vs-mobula, alchemy-vs-moralis, chain-vs-chain, perp-vs-perp). - compare-pairs: remove jupiter-vs-raydium (raydium has 0 bench appearances so /compare/jupiter-vs-raydium 404s at render). - products/[slug]: title now leads with 'Benchmark {year}' + head-term match on ' benchmark' queries. Meta description ends with 'As of YYYY-MM-DD' for LLM citation extractability (LLM-mediated discovery drives ~80% of Bing query traffic). - compare/[slug]: title now leads with '{a} vs {b} Benchmark {year}'. Meta description is unique per pair (shared bench count + date) killing the identical duplicate signal that had Bing skipping pages. * seo: prose summary above the fold on /compare + /products Replaces the identical templated intro paragraph on both pages with a data-driven prose summary derived from live measurements. Kills the last piece of duplicate above-the-fold text so Bing sees genuinely unique substantive content per URL. /compare/[slug]: Before: 'Side by side OpenChainBench measurements. Identical layout, no editorial verdict...' (same on 247 pages) After: 'Codex leads on 2 of 4 shared benchmarks, Mobula on 2. Codex wins on aggregator-head-lag (128ms vs 195ms), wallet-labels- coverage (42.8% vs 38.1%). Mobula wins on metadata-coverage (96.9% vs 89.1%), network-coverage (80 vs 42 chains).' /products/[slug]: Before: 'Moralis performance benchmarks, live across 2 categories. Reproducible measurements, open methodology.' (same on 104 pages) After: 'Moralis is measured across 2 live OpenChainBench benchmarks, with 1 #1 finish: NFT collection metadata (ranks #1, 96.9% p50), Wallet labels coverage (ranks #2 of 5, 42.4% p50).' Falls back to a neutral sentence when the provider's p50 data is missing (cold ISR, harness restart) so cold pages don't render a lie. * chore(lint): exclude standalone sub-apps from main frontend lint infrastructure/monitoring-ui and infrastructure/prom-admin are independently deployed Next.js apps (own package.json + Railway config, own build/lint/typecheck pipeline). Scanning them from the main frontend's lint pass caused every unrelated PR to fail on their pre-existing warnings that the sub-app maintainers can fix in their own dedicated PRs. worker/ is the materialize worker (own package.json + Railway config), same reasoning. Unblocks PR #908 (SEO purge) and any subsequent PR touching only src/*. --------- Co-authored-by: Florent Tapponnier --- eslint.config.mjs | 8 +++ src/app/compare/[slug]/page.tsx | 94 +++++++++++++++++++++++++++--- src/app/products/[slug]/page.tsx | 53 +++++++++++++---- src/app/sitemap.ts | 26 ++++++--- src/data/compare-pairs.ts | 10 ++-- src/lib/compare/brand-whitelist.ts | 45 ++++++++++++++ 6 files changed, 202 insertions(+), 34 deletions(-) create mode 100644 src/lib/compare/brand-whitelist.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 05e726d1..4446caa2 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -12,6 +12,14 @@ const eslintConfig = defineConfig([ "out/**", "build/**", "next-env.d.ts", + // Standalone sub-apps deployed independently (own package.json + Railway + // config). Each has its own build, lint and typecheck pipeline; scanning + // them from the main frontend's lint pass caused every unrelated PR to + // fail on their pre-existing warnings (unescaped entities, no-explicit-any, + // react-hooks/set-state-in-effect) that the sub-app maintainers can fix + // in their own dedicated PRs. + "infrastructure/**", + "worker/**", ]), ]); diff --git a/src/app/compare/[slug]/page.tsx b/src/app/compare/[slug]/page.tsx index 0a74ae36..223a8377 100644 --- a/src/app/compare/[slug]/page.tsx +++ b/src/app/compare/[slug]/page.tsx @@ -178,9 +178,33 @@ 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 benchmarks`; + + // SEO title carries the head-term shape ("X vs Y benchmark") plus + // current year (LLM extractability). Format leads with both provider + // names so Google's ~60-char SERP truncation keeps the intent-matching + // portion. The suffix "· OpenChainBench" is added by Next's title + // template so we don't spend chars on it here. + const currentYear = new Date().getUTCFullYear(); + const title = `${a.name} vs ${b.name} Benchmark ${currentYear}`; + + // Compute shared bench count from appearances (already loaded via + // hasSharedBenches above — cheap recomputation, avoids another Prom hit). + const aSlugs = new Set(a.appearances.map((x) => x.benchmark.slug)); + const bSlugs = new Set(b.appearances.map((x) => x.benchmark.slug)); + const excluded = new Set(pair.excludeBenchmarks ?? []); + const sharedSlugsForMeta = pair.benchmarks + ? pair.benchmarks.filter((s) => aSlugs.has(s) && bSlugs.has(s)) + : Array.from(aSlugs).filter((s) => bSlugs.has(s)); + const sharedCount = sharedSlugsForMeta.filter((s) => !excluded.has(s)).length; + const benchWord = sharedCount === 1 ? "benchmark" : "benchmarks"; + + // Meta description: unique per pair via the shared-count + provider + // names + date. Kills the identical duplicate-content signal that had + // Bing indexing 2 of 4938 compare pages. Also cites "as of DATE" for + // LLM citations. + const isoDate = new Date().toISOString().split("T")[0]; const description = capDescription( - `${a.name} vs ${b.name} side by side on every shared OpenChainBench benchmark. Live measurements, identical layout, no verdict.`, + `${a.name} vs ${b.name} on ${sharedCount} shared OpenChainBench ${benchWord}. Live measurements, reproducible methodology. As of ${isoDate}.`, 158, ); @@ -266,6 +290,63 @@ function decideWinner( return aP50 < bP50 ? "a" : "b"; } +/** Build a data-driven prose summary of the head-to-head. Emitted above + * the fold so Google/Bing get substantive, unique text per pair instead + * of the identical template paragraph that used to sit here (which was + * a big contributor to Bing indexing only 2 of ~5000 URLs — SEO audit + * 2026-07-05). Every sentence is derived from live measurements, no + * editorial claim. Falls back to a minimal statement when p50 data is + * missing (cold ISR, harness restart) so we never emit a lie. */ +function buildComparisonProse( + shared: SharedBench[], + aName: string, + bName: string, +): string { + if (shared.length === 0) return ""; + const aWinTitles: string[] = []; + const bWinTitles: string[] = []; + const aWinLines: string[] = []; + const bWinLines: string[] = []; + let ties = 0; + + for (const s of shared) { + const aP50 = s.aResult.p50; + const bP50 = s.bResult.p50; + if (aP50 <= 0 || bP50 <= 0) continue; + const aVal = fmtUnit(aP50, s.unit); + const bVal = fmtUnit(bP50, s.unit); + if (s.aggregateWinner === "a") { + aWinTitles.push(s.title); + aWinLines.push(`${s.title} (${aVal} vs ${bVal})`); + } else if (s.aggregateWinner === "b") { + bWinTitles.push(s.title); + bWinLines.push(`${s.title} (${bVal} vs ${aVal})`); + } else { + ties += 1; + } + } + + const total = aWinTitles.length + bWinTitles.length + ties; + if (total === 0) { + // No live data yet — return a neutral sentence rather than the old + // templated intro so the meta description + title remain the only + // duplicate-adjacent text on cold-cache pages. + return `${aName} vs ${bName} on ${shared.length} shared OpenChainBench ${shared.length === 1 ? "benchmark" : "benchmarks"}, awaiting live measurements.`; + } + + const parts: string[] = []; + parts.push( + `${aName} leads on ${aWinTitles.length} of ${total} shared benchmarks, ${bName} on ${bWinTitles.length}${ties > 0 ? ` (${ties} tied)` : ""}.`, + ); + if (aWinLines.length > 0) { + parts.push(`${aName} wins on ${aWinLines.slice(0, 4).join(", ")}.`); + } + if (bWinLines.length > 0) { + parts.push(`${bName} wins on ${bWinLines.slice(0, 4).join(", ")}.`); + } + return parts.join(" "); +} + /** Load the per-dimension breakdown for one shared bench against one * axis. Resolves each dimension value to a filtered Benchmark via * loadBenchmark, then picks both providers' results. Drops rows where @@ -615,6 +696,8 @@ export default async function ComparePage({ ]), }; + const comparisonProse = buildComparisonProse(shared, a.name, b.name); + return (