diff --git a/.claude/skills/contribute-benchmark/SKILL.md b/.claude/skills/contribute-benchmark/SKILL.md index 65e094c1..b0d732e9 100644 --- a/.claude/skills/contribute-benchmark/SKILL.md +++ b/.claude/skills/contribute-benchmark/SKILL.md @@ -5,7 +5,7 @@ description: Walks a contributor through adding a new benchmark to OpenChainBenc You are helping someone add a new benchmark to OpenChainBench, an open registry of crypto-infrastructure benchmarks. -If the user does not already have the repo cloned, your first step is to suggest `git clone https://github.com/OpenChainBench/OpenChainBench && cd OpenChainBench`. Everything below assumes that working directory. +If the user does not already have the repo cloned, your first step is to suggest `git clone https://github.com/ChainBench/OpenChainBench && cd OpenChainBench`. Everything below assumes that working directory. ## Mental model @@ -24,7 +24,7 @@ Walk the user through these six steps in order. Do not skip ahead. ### 1. Open an issue -Direct them to the `Propose a benchmark` issue template at https://github.com/OpenChainBench/OpenChainBench/issues/new?template=new-benchmark.yml. Maintainers need to see the proposed metric, providers, methodology and harness location before code review. Brainstorm-stage ideas belong in https://github.com/OpenChainBench/OpenChainBench/discussions/categories/ideas instead. +Direct them to the `Propose a benchmark` issue template at https://github.com/ChainBench/OpenChainBench/issues/new?template=new-benchmark.yml. Maintainers need to see the proposed metric, providers, methodology and harness location before code review. Brainstorm-stage ideas belong in https://github.com/ChainBench/OpenChainBench/discussions/categories/ideas instead. ### 2. Write the spec diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md index e46d62f9..cacf0937 100644 --- a/.github/CODE_OF_CONDUCT.md +++ b/.github/CODE_OF_CONDUCT.md @@ -5,7 +5,7 @@ OpenChainBench publishes performance numbers about real services run by real peo ## Expected behavior - **Critique numbers, not people.** It's fair to question a methodology, a sample window, a regional bias. It's not fair to suggest someone is incompetent or dishonest. -- **Cite, don't assert.** If you think a published figure is wrong, file a [provider correction](https://github.com/OpenChainBench/OpenChainBench/issues/new?template=provider-correction.yml) with a reproducer. "This number is bullshit" without a reproducer is a non-issue. +- **Cite, don't assert.** If you think a published figure is wrong, file a [provider correction](https://github.com/ChainBench/OpenChainBench/issues/new?template=provider-correction.yml) with a reproducer. "This number is bullshit" without a reproducer is a non-issue. - **Disclose conflicts.** If you work for a provider that's measured here, say so when you participate in discussions about that provider. It doesn't disqualify your input. it just keeps the conversation honest. - **Respect contributors' time.** Maintainers are not obligated to debate. If a thread is going in circles, a maintainer may close it with a dated decision and that decision stands until new evidence is filed. @@ -28,7 +28,7 @@ Decisions are recorded in the affected thread. ## Reporting -For private reports. including suspected manipulation, harassment, or undisclosed conflicts of interest. email **conduct@openchainbench.com** or open a [private security advisory](https://github.com/OpenChainBench/OpenChainBench/security/advisories/new) (the same channel works for non-security disclosures). +For private reports. including suspected manipulation, harassment, or undisclosed conflicts of interest. email **conduct@openchainbench.com** or open a [private security advisory](https://github.com/ChainBench/OpenChainBench/security/advisories/new) (the same channel works for non-security disclosures). We treat reporters' identities as confidential by default. diff --git a/.github/DISCUSSIONS_AND_PROJECT_SETUP.md b/.github/DISCUSSIONS_AND_PROJECT_SETUP.md index 870b4856..7d31e822 100644 --- a/.github/DISCUSSIONS_AND_PROJECT_SETUP.md +++ b/.github/DISCUSSIONS_AND_PROJECT_SETUP.md @@ -21,7 +21,7 @@ Default categories like "General" and "Polls" should be deleted to keep the surf - **Name**: `OpenChainBench Roadmap` - **Visibility**: Public -- **Linked repository**: `OpenChainBench/OpenChainBench` +- **Linked repository**: `ChainBench/OpenChainBench` ### Columns @@ -41,7 +41,7 @@ Requested → Approved → In progress → Live → Archived In Project Settings → Workflows, enable: -- **Auto-add to project**. for issues in `OpenChainBench/OpenChainBench` matching the filter `is:issue label:bench-request`. They land in `Requested` automatically. +- **Auto-add to project**. for issues in `ChainBench/OpenChainBench` matching the filter `is:issue label:bench-request`. They land in `Requested` automatically. - **Item closed → Archived**. when an issue is closed without a `live` label, move it to `Archived`. - **PR merged → Live** (manual; GitHub doesn't have a native trigger, so a maintainer moves the card after merge). diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 00392232..483e473b 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,14 +1,14 @@ blank_issues_enabled: false contact_links: - name: 💡 Idea or rough proposal - url: https://github.com/OpenChainBench/OpenChainBench/discussions/new?category=ideas + url: https://github.com/ChainBench/OpenChainBench/discussions/new?category=ideas about: Have an idea but not ready for a formal benchmark proposal? Start a discussion in Ideas. - name: 🙋 Question (How do I…?) - url: https://github.com/OpenChainBench/OpenChainBench/discussions/new?category=q-a + url: https://github.com/ChainBench/OpenChainBench/discussions/new?category=q-a about: Ask a question about a spec, harness, methodology, or how the site works. - name: 📊 Show & tell - url: https://github.com/OpenChainBench/OpenChainBench/discussions/new?category=show-and-tell + url: https://github.com/ChainBench/OpenChainBench/discussions/new?category=show-and-tell about: Show a fork, a dashboard, or a benchmark you ran locally. - name: 🔐 Security disclosure - url: https://github.com/OpenChainBench/OpenChainBench/security/advisories/new + url: https://github.com/ChainBench/OpenChainBench/security/advisories/new about: Privately report a vulnerability. Do not file a public issue for security problems. diff --git a/.github/ISSUE_TEMPLATE/new-benchmark.yml b/.github/ISSUE_TEMPLATE/new-benchmark.yml index 3968a601..e8ca74f1 100644 --- a/.github/ISSUE_TEMPLATE/new-benchmark.yml +++ b/.github/ISSUE_TEMPLATE/new-benchmark.yml @@ -10,7 +10,7 @@ body: OpenChainBench prefers to align on **what** is measured and **how** before any code is written. Fill out the sections below. fields marked required are the bare minimum needed for the maintainers to give meaningful feedback. If you're not sure on some of them, write what you have and leave a note. - Have a rough idea but not ready to fill all this? Open a thread in [Discussions → Ideas](https://github.com/OpenChainBench/OpenChainBench/discussions/categories/ideas) instead. + Have a rough idea but not ready to fill all this? Open a thread in [Discussions → Ideas](https://github.com/ChainBench/OpenChainBench/discussions/categories/ideas) instead. - type: input id: metric attributes: @@ -97,5 +97,5 @@ body: options: - label: I understand that the harness code, methodology, and any corrections will be published under the project's MIT/CC-BY-4.0 license. required: true - - label: I have searched [existing benchmarks](https://github.com/OpenChainBench/OpenChainBench/tree/main/benchmarks) and [open issues](https://github.com/OpenChainBench/OpenChainBench/issues?q=is%3Aissue+label%3Abench-request) for duplicates. + - label: I have searched [existing benchmarks](https://github.com/ChainBench/OpenChainBench/tree/main/benchmarks) and [open issues](https://github.com/ChainBench/OpenChainBench/issues?q=is%3Aissue+label%3Abench-request) for duplicates. required: true diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 150f0331..1e7bf15e 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -2,7 +2,7 @@ ## Reporting a vulnerability -Open a [private security advisory](https://github.com/OpenChainBench/OpenChainBench/security/advisories/new) on this repository. Do **not** open a public issue for a security report. the advisory mechanism keeps the disclosure private until a fix is in place. +Open a [private security advisory](https://github.com/ChainBench/OpenChainBench/security/advisories/new) on this repository. Do **not** open a public issue for a security report. the advisory mechanism keeps the disclosure private until a fix is in place. If GitHub is not an option, email **security@openchainbench.com** with: diff --git a/.github/SUPPORT.md b/.github/SUPPORT.md index 4cf1799d..42f72408 100644 --- a/.github/SUPPORT.md +++ b/.github/SUPPORT.md @@ -6,13 +6,13 @@ OpenChainBench is community-run. Before opening an issue, pick the right channel | Goal | Where to go | | --- | --- | -| Propose a new benchmark formally | [New issue → 📊 Propose a benchmark](https://github.com/OpenChainBench/OpenChainBench/issues/new?template=new-benchmark.yml) | -| Float an idea before writing it up | [Discussions → Ideas](https://github.com/OpenChainBench/OpenChainBench/discussions/categories/ideas) | -| Ask a question about a spec, harness, methodology, or the site | [Discussions → Q&A](https://github.com/OpenChainBench/OpenChainBench/discussions/categories/q-a) | -| Show a fork or a dashboard you built | [Discussions → Show & tell](https://github.com/OpenChainBench/OpenChainBench/discussions/categories/show-and-tell) | -| Report a number that looks wrong on the site | [New issue → 🐞 Data quality](https://github.com/OpenChainBench/OpenChainBench/issues/new?template=data-quality.yml) | -| Submit a provider correction (you measured a different number) | [New issue → ✏️ Provider correction](https://github.com/OpenChainBench/OpenChainBench/issues/new?template=provider-correction.yml) | -| Privately report a security vulnerability | [Security advisories](https://github.com/OpenChainBench/OpenChainBench/security/advisories/new) | +| Propose a new benchmark formally | [New issue → 📊 Propose a benchmark](https://github.com/ChainBench/OpenChainBench/issues/new?template=new-benchmark.yml) | +| Float an idea before writing it up | [Discussions → Ideas](https://github.com/ChainBench/OpenChainBench/discussions/categories/ideas) | +| Ask a question about a spec, harness, methodology, or the site | [Discussions → Q&A](https://github.com/ChainBench/OpenChainBench/discussions/categories/q-a) | +| Show a fork or a dashboard you built | [Discussions → Show & tell](https://github.com/ChainBench/OpenChainBench/discussions/categories/show-and-tell) | +| Report a number that looks wrong on the site | [New issue → 🐞 Data quality](https://github.com/ChainBench/OpenChainBench/issues/new?template=data-quality.yml) | +| Submit a provider correction (you measured a different number) | [New issue → ✏️ Provider correction](https://github.com/ChainBench/OpenChainBench/issues/new?template=provider-correction.yml) | +| Privately report a security vulnerability | [Security advisories](https://github.com/ChainBench/OpenChainBench/security/advisories/new) | | See what's planned and where to contribute code | [Roadmap project board](https://github.com/orgs/OpenChainBench/projects) | | Read about how the data is produced | [`benchmarks/README.md`](../benchmarks/README.md) and [`harnesses/README.md`](../harnesses/README.md) | diff --git a/.github/workflows/staging-deploy.yml b/.github/workflows/staging-deploy.yml index 65233157..528eec40 100644 --- a/.github/workflows/staging-deploy.yml +++ b/.github/workflows/staging-deploy.yml @@ -1,15 +1,5 @@ name: Vercel staging deploy -# Auto-deploy to Vercel Preview on every push to dev. Workaround for the -# Vercel ↔ GitHub App pairing being unreliable across the OpenChainBench -# (user account) + mobula-labs (Vercel team) setup. The Action calls the -# Vercel CLI directly with VERCEL_TOKEN, bypassing the App webhook flow. -# -# Required GitHub secrets (Settings → Secrets and variables → Actions): -# VERCEL_TOKEN — vercel.com/account/tokens, scope = mobula-labs -# VERCEL_ORG_ID — ***SCRUBBED*** -# VERCEL_PROJECT_ID — ***SCRUBBED*** - on: push: branches: @@ -61,7 +51,7 @@ jobs: vercel alias set "${{ steps.deploy.outputs.url }}" \ staging-openchainbench.vercel.app \ --token=${{ secrets.VERCEL_TOKEN }} \ - --scope=mobula-labs + --scope=${{ secrets.VERCEL_ORG_ID }} - name: Comment summary run: | diff --git a/AGENTS.md b/AGENTS.md index d9a6850a..7502e8c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ This version has breaking changes. APIs, conventions, and file structure may all Two long-lived branches: - `main` → openchainbench.com (production). Vercel auto-deploy on push is **disabled**; production deploys are **manual** via `vercel --prod` from a checkout of `main`. -- `dev` → staging. Each push triggers `.github/workflows/vercel-staging.yml` which calls Vercel CLI with a token and emits a Preview URL. The Vercel GitHub App is NOT used for staging because the OpenChainBench user account + mobula-labs Vercel team pairing wouldn't propagate. +- `dev` → staging. Each push triggers `.github/workflows/staging-deploy.yml` which calls Vercel CLI with a token and emits a Preview URL. **Standard flow:** diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b089f6cb..4b72e48b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,16 +20,16 @@ OpenChainBench is a federation. Each harness is hosted by whoever wrote it. the | Goal | Right channel | | --- | --- | -| Float a rough idea | [Discussions → Ideas](https://github.com/OpenChainBench/OpenChainBench/discussions/categories/ideas) | -| Ask a methodology / harness question | [Discussions → Q&A](https://github.com/OpenChainBench/OpenChainBench/discussions/categories/q-a) | -| Propose a benchmark formally | [Issue → 📊 Propose a benchmark](https://github.com/OpenChainBench/OpenChainBench/issues/new?template=new-benchmark.yml) | -| Report a number that looks wrong | [Issue → 🐞 Data quality](https://github.com/OpenChainBench/OpenChainBench/issues/new?template=data-quality.yml) | -| Submit a provider correction | [Issue → ✏️ Provider correction](https://github.com/OpenChainBench/OpenChainBench/issues/new?template=provider-correction.yml) | +| Float a rough idea | [Discussions → Ideas](https://github.com/ChainBench/OpenChainBench/discussions/categories/ideas) | +| Ask a methodology / harness question | [Discussions → Q&A](https://github.com/ChainBench/OpenChainBench/discussions/categories/q-a) | +| Propose a benchmark formally | [Issue → 📊 Propose a benchmark](https://github.com/ChainBench/OpenChainBench/issues/new?template=new-benchmark.yml) | +| Report a number that looks wrong | [Issue → 🐞 Data quality](https://github.com/ChainBench/OpenChainBench/issues/new?template=data-quality.yml) | +| Submit a provider correction | [Issue → ✏️ Provider correction](https://github.com/ChainBench/OpenChainBench/issues/new?template=provider-correction.yml) | | See what's planned | [Roadmap project board](https://github.com/orgs/OpenChainBench/projects) | ## Submitting a benchmark -1. **Open an issue** with the [📊 Propose a benchmark template](https://github.com/OpenChainBench/OpenChainBench/issues/new?template=new-benchmark.yml). Sketch the metric, providers, methodology, where you'll host the harness. get feedback before you write code. The issue lands in the `Requested` column of the roadmap. +1. **Open an issue** with the [📊 Propose a benchmark template](https://github.com/ChainBench/OpenChainBench/issues/new?template=new-benchmark.yml). Sketch the metric, providers, methodology, where you'll host the harness. get feedback before you write code. The issue lands in the `Requested` column of the roadmap. 2. **Write the spec.** Drop a YAML at `benchmarks/.yml`. The format is described in [`benchmarks/README.md`](./benchmarks/README.md) and validated by `src/lib/spec-schema.ts`. 3. **Build the harness** at `harnesses//`. A harness is a data producer only. it exposes `/metrics` over HTTPS with the metric names and labels your spec references. See [`harnesses/README.md`](./harnesses/README.md) for the contract and the existing Go harnesses as reference implementations. 4. **Deploy the harness** on whatever infra fits. Railway, Fly, Cloud Run, a VPS. Expose `/metrics` over HTTPS at a stable public URL. You own the runtime, the secrets and the budget. The maintainers never see your API keys. @@ -56,7 +56,7 @@ pnpm build # production build ## Corrections -If you can't reproduce a number, file a [provider correction](https://github.com/OpenChainBench/OpenChainBench/issues/new?template=provider-correction.yml) (you measured a different value for your service) or a [data quality issue](https://github.com/OpenChainBench/OpenChainBench/issues/new?template=data-quality.yml) (the site is showing something obviously wrong or stale). Material errors are corrected in place with a dated note on the report. +If you can't reproduce a number, file a [provider correction](https://github.com/ChainBench/OpenChainBench/issues/new?template=provider-correction.yml) (you measured a different value for your service) or a [data quality issue](https://github.com/ChainBench/OpenChainBench/issues/new?template=data-quality.yml) (the site is showing something obviously wrong or stale). Material errors are corrected in place with a dated note on the report. ## Code of conduct & security diff --git a/README.md b/README.md index 104ede6e..bbefe614 100644 --- a/README.md +++ b/README.md @@ -255,7 +255,7 @@ To render the site against your local harness, run a local Prometheus scraping ` Full guide in [CONTRIBUTING.md](./CONTRIBUTING.md). For a concrete end-to-end example, read [`docs/walkthrough.md`](./docs/walkthrough.md). Short version: -1. **Open an issue** with the [📊 Propose a benchmark template](https://github.com/OpenChainBench/OpenChainBench/issues/new?template=new-benchmark.yml). Sketch the metric, providers, methodology. get feedback before you build. Want to brainstorm first? Use [Discussions → Ideas](https://github.com/OpenChainBench/OpenChainBench/discussions/categories/ideas) instead. +1. **Open an issue** with the [📊 Propose a benchmark template](https://github.com/ChainBench/OpenChainBench/issues/new?template=new-benchmark.yml). Sketch the metric, providers, methodology. get feedback before you build. Want to brainstorm first? Use [Discussions → Ideas](https://github.com/ChainBench/OpenChainBench/discussions/categories/ideas) instead. 2. **Write the spec** at `benchmarks/.yml`. Format documented in [`benchmarks/README.md`](./benchmarks/README.md), validated by `src/lib/spec-schema.ts`. 3. **Build the harness** in `harnesses//`. Any language works as long as it exposes `/metrics` over HTTPS with the metric names and labels your spec references. The harness is a data producer only. no Prometheus, Grafana, or Alertmanager packaging. 4. **Deploy the harness** on whatever infra fits. Railway, Fly, Cloud Run, a VPS, even a home server with a static IP. Expose `/metrics` over HTTPS at a stable public URL. You own the runtime, the secrets and the budget. @@ -314,11 +314,11 @@ PNG URL — the downloaded image matches what the user is looking at. ## Community -- 💡 [Discussions → Ideas](https://github.com/OpenChainBench/OpenChainBench/discussions/categories/ideas). brainstorm new benchmarks before writing them up -- 🙋 [Discussions → Q&A](https://github.com/OpenChainBench/OpenChainBench/discussions/categories/q-a). methodology / harness / spec questions -- 📊 [Discussions → Show & tell](https://github.com/OpenChainBench/OpenChainBench/discussions/categories/show-and-tell). share forks and dashboards +- 💡 [Discussions → Ideas](https://github.com/ChainBench/OpenChainBench/discussions/categories/ideas). brainstorm new benchmarks before writing them up +- 🙋 [Discussions → Q&A](https://github.com/ChainBench/OpenChainBench/discussions/categories/q-a). methodology / harness / spec questions +- 📊 [Discussions → Show & tell](https://github.com/ChainBench/OpenChainBench/discussions/categories/show-and-tell). share forks and dashboards - 🗺️ [Roadmap](https://github.com/orgs/OpenChainBench/projects). what's planned and what's live -- 🐞 [New issue](https://github.com/OpenChainBench/OpenChainBench/issues/new/choose). formal benchmark proposal, data-quality flag, or provider correction +- 🐞 [New issue](https://github.com/ChainBench/OpenChainBench/issues/new/choose). formal benchmark proposal, data-quality flag, or provider correction - See [SUPPORT.md](./.github/SUPPORT.md) for the full triage matrix. ## SEO TODOs @@ -339,7 +339,7 @@ Tracking the work to make benchmark pages rank for the queries they target. - Site. [openchainbench.com](https://openchainbench.com) - Live stream. folded into [openchainbench.com](https://openchainbench.com) - Twitter. [@openchainbench](https://twitter.com/openchainbench) -- GitHub. [OpenChainBench/OpenChainBench](https://github.com/OpenChainBench/OpenChainBench) +- GitHub. [ChainBench/OpenChainBench](https://github.com/ChainBench/OpenChainBench) ## License diff --git a/benchmarks/aggregator-head-lag.yml b/benchmarks/aggregator-head-lag.yml index 9187b13e..7be2f7d4 100644 --- a/benchmarks/aggregator-head-lag.yml +++ b/benchmarks/aggregator-head-lag.yml @@ -19,7 +19,13 @@ seo_intro: | pages quote "real-time" without a number; this page quotes the number. Mobula's WebSocket, Codex's GraphQL feed and GeckoTerminal's REST endpoint are watched from three regions (us-east, eu-west, sgp) - against canonical-tip archive nodes on Base, BNB Chain and Solana. + against canonical-tip archive + nodes on Base, BNB Chain and Solana. Because each chain has its own + base block cadence (Solana's ~400 ms slots vs Base/BNB's 2-3 s + blocks), the headline winner shifts by chain. On Solana the leader + is {{best_name:chain:solana}} at {{best_p50:chain:solana}}; on Base + it's {{best_name:chain:base}} at {{best_p50:chain:base}}; on BNB + Chain it's {{best_name:chain:bnb}} at {{best_p50:chain:bnb}}. p50 and p99 are computed via Prometheus `quantile_over_time` over a 24-hour window so the leaderboard reflects sustained freshness, not a cherry-picked cold-start sample. The same harness tracks presence @@ -45,21 +51,23 @@ methodology: - "Cardinality: 3 aggregators × 3 chains × 3 regions = 27 active series." findings: - - "{{best_name}} currently leads head lag at {{best_p50}} (p50, 24 h) across {{count}} measured providers. The number is the wall-clock interval between a swap settling on chain and that swap appearing on the provider's feed, cross-region median." - - "{{name:mobula}} returns {{p50:mobula}} (p50, 24 h). WebSocket feeds compress latency by pushing events the moment the harness emits them rather than waiting for the next poll cycle, which is the architectural reason real-time integrations prefer them over REST." - - "{{name:codex}} clocks {{p50:codex}} (p50, 24 h). GraphQL subscriptions are a server-push pattern in practice and cluster near WebSocket performance on liquid pools, but tail latency tends to widen on long-tail tokens with sparse events." - - "{{name:geckoterminal}} sits at {{p50:geckoterminal}} (p50, 24 h). REST polling adds the poll interval to every read, so head lag tracks the publisher's chosen cadence rather than raw infrastructure speed." + - "Across the unfiltered cross-chain aggregate the leader is {{best_name}} at {{best_p50}} (p50, 24 h) over {{count}} measured providers, but that headline number mixes chains with very different baseline cadences (Solana 400 ms slots vs Base/BNB 2-3 s blocks) so the per-chain breakdown below is what to read for an integration decision." + - "On Solana the leader is {{best_name:chain:solana}} at {{best_p50:chain:solana}} (p50, 24 h); the trailer is {{worst_name:chain:solana}} at {{worst_p50:chain:solana}}. Slot cadence is sub-second, so any second-scale aggregate latency is on the provider's pipeline, not the chain." + - "On Base the leader is {{best_name:chain:base}} at {{best_p50:chain:base}} (p50, 24 h); the trailer is {{worst_name:chain:base}} at {{worst_p50:chain:base}}. Base targets a 2 s block cadence, so head lag here measures pipeline latency on top of the chain's natural block interval." + - "On BNB Chain the leader is {{best_name:chain:bnb}} at {{best_p50:chain:bnb}} (p50, 24 h); the trailer is {{worst_name:chain:bnb}} at {{worst_p50:chain:bnb}}. BNB's ~3 s blocks set the floor; the spread between leader and trailer is the provider-side delta." + - "{{name:mobula}} returns {{p50:mobula}} (p50, 24 h) cross-chain. WebSocket feeds compress latency by pushing events the moment the harness emits them rather than waiting for the next poll cycle, which is the architectural reason real-time integrations prefer them over REST." + - "{{name:codex}} clocks {{p50:codex}} (p50, 24 h) cross-chain. GraphQL subscriptions are a server-push pattern in practice and cluster near WebSocket performance on liquid pools, but tail latency tends to widen on long-tail tokens with sparse events." + - "{{name:geckoterminal}} sits at {{p50:geckoterminal}} (p50, 24 h) cross-chain. REST polling adds the poll interval to every read, so head lag tracks the publisher's chosen cadence rather than raw infrastructure speed." - "p99 is the integration-grade number. The gap between p50 and {{p99:mobula}} / {{p99:codex}} / {{p99:geckoterminal}} is what a live UI feels when a chain spikes or a region's path degrades." - - "{{worst_name}} trails at {{worst_p50}} (p50, 24 h). On a real-time trading UI the gap between leader and laggard becomes visible to end users when chart candles stutter or the trade tape lags behind the explorer." -source: https://github.com/OpenChainBench/OpenChainBench/tree/main/harnesses/aggregator-head-lag +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/aggregator-head-lag prometheus: window: 24h faq: - q: "Which crypto data API has the lowest latency right now?" - a: "{{best_name}} currently leads at {{best_p50}} (p50, 24 h) across {{count}} measured providers. The leaderboard re-sorts every 15 seconds against fresh Prometheus samples, so the answer reflects the last 24 hours of measured head lag on Base, BNB Chain and Solana from three regions, not a marketing-page claim." + a: "It depends on the chain, because each chain's base block cadence sets a different floor. On Solana the leader is {{best_name:chain:solana}} at {{best_p50:chain:solana}} (p50, 24 h). On Base it's {{best_name:chain:base}} at {{best_p50:chain:base}}. On BNB Chain it's {{best_name:chain:bnb}} at {{best_p50:chain:bnb}}. The unfiltered cross-chain aggregate puts {{best_name}} on top at {{best_p50}}, but that ranking is influenced by chain mix as much as by provider pipeline speed - read the per-chain numbers if you're picking a provider for a specific chain." - q: "What is head lag in a crypto data feed?" a: "Head lag is the wall-clock interval between a transaction landing in a confirmed block on chain and the same transaction appearing on the data provider's feed. A provider with 200 ms head lag pushes the event a fifth of a second after it settled; a provider with 6 s head lag pushes it after six seconds. The number controls how live a trading UI, a chart or a PnL tracker actually feels when the underlying chain moves." - q: "Is WebSocket faster than REST for crypto market data?" @@ -162,3 +170,4 @@ providers: - region: ap-southeast p50: quantile_over_time(0.50, head_lag_seconds{aggregator="geckoterminal", region="sgp"}[24h]) * 1000 series: avg_over_time(head_lag_seconds{aggregator="geckoterminal", region="sgp"}[1h]) * 1000 + diff --git a/benchmarks/bridge-fee.yml b/benchmarks/bridge-fee.yml index 890a266b..458204fc 100644 --- a/benchmarks/bridge-fee.yml +++ b/benchmarks/bridge-fee.yml @@ -46,16 +46,26 @@ methodology: - "Failures (quote_failed, unsupported route) excluded from cost aggregates and counted toward success rate." - "Provider types are surfaced as badges: direct protocols (Debridge) charge their own bridging fees; aggregators (LiFi) route through whichever underlying bridge is cheapest, so their effective fee includes that bridge's cost plus any aggregator markup; intent / relay layers (Mobula, Relay) bake cost into the spread of a settlement intent rather than charging an explicit fee." +# Per-corridor leader rewrite deferred: this YAML does not yet declare +# `dimensions:` for `from_chain`/`to_chain` / corridor. The underlying +# metric already carries those labels (see comment block lower in this +# file), so per-corridor placeholders can be wired in once dimensions +# are added (separate code/data PR). Until then, the {{best_name}} / +# {{worst_name}} placeholders below resolve to the cross-corridor +# aggregate, which is honest because every provider in this bench is +# queried on the same Solana/Base/Arbitrum corridors. but the text is +# softened to make the aggregation explicit rather than implying a +# single winning corridor. findings: - - "{{best_name}} currently leads at {{best_p50}} (p50, 24 h) on the $300 USDC corridor, across {{count}} measured bridges." + - "{{best_name}} currently leads the cross-corridor aggregate at {{best_p50}} (p50, 24 h) for $300 USDC trades, across {{count}} measured bridges. The number averages over the Solana/Base/Arbitrum corridors the harness sweeps; per-corridor leaders can diverge sharply on routes where one solver has inventory and another does not." - "{{name:mobula}} returns {{p50:mobula}} (p50, 24 h). Intent layers compress fees, slippage and destination gas into a single spread, so the effective number reads lower than aggregators that route through a paid underlying bridge." - "{{name:lifi}} sits at {{p50:lifi}} (p50, 24 h). Aggregators pay the underlying bridge plus a thin markup, so their effective cost trails intent layers on liquid corridors where multiple solvers compete on the same route." - "{{name:debridge}} clocks {{p50:debridge}} (p50, 24 h). Direct protocols front-load cost into a native-token fee, which dominates on $300 trades because the fixed component does not scale with notional." - - "{{worst_name}} trails at {{worst_p50}} (p50, 24 h). On a $300 trade the gap between leader and laggard is mostly the fixed-fee floor of the slower path, not relative spread efficiency." + - "{{worst_name}} trails the cross-corridor aggregate at {{worst_p50}} (p50, 24 h). On a $300 trade the gap between leader and laggard is mostly the fixed-fee floor of the slower path, not relative spread efficiency, and the laggard on one corridor can be the leader on another once corridor dimensions are surfaced." faq: - q: "What is the cheapest cross-chain bridge right now?" - a: "{{best_name}} currently leads the leaderboard at {{best_p50}} (p50, 24 h) for $300 USDC trades across the measured corridors (Solana, Base, Arbitrum). The leaderboard re-sorts every five minutes against fresh Prometheus samples, so the answer is always anchored to the last 24 hours of live data rather than a frozen table from a blog post." + a: "{{best_name}} currently leads the cross-corridor aggregate at {{best_p50}} (p50, 24 h) for $300 USDC trades, averaged across the Solana/Base/Arbitrum corridors the harness sweeps. The leaderboard re-sorts every five minutes against fresh Prometheus samples, so the answer is anchored to the last 24 hours of live data rather than a frozen table from a blog post. Per-corridor leaders can diverge from this aggregate when a solver has deep inventory on one specific route and not on others, that breakdown is on the roadmap once corridor dimensions are surfaced in the page UI." - q: "How much does it cost to bridge $300 USDC?" a: "Total cost varies between providers. The current p50 across {{count}} bridges spans from {{best_p50}} (leader) up to {{worst_p50}} (laggard) of the $300 notional. The figure already includes fees, slippage and destination gas because we report `bridge_cost_percent`, the all-in number that actually leaves the user's wallet, not the explicit fee field providers advertise." - q: "Why do bridge fees vary 10x between providers?" @@ -67,7 +77,7 @@ faq: - q: "Why does the cheapest bridge change throughout the day?" a: "Solver competition is the main driver. Intent layers like Mobula and Relay rank quotes from a rotating set of solvers whose inventory and risk appetite shift with destination-chain volatility and time of day. When Solana gas spikes or an L2 sees a deposit cluster, solvers reprice, and the leaderboard re-orders. The 5-minute scrape cadence catches these moves; the 24-hour p50 smooths them so the headline number reflects sustained competitiveness rather than a single favourable quote." -source: https://github.com/OpenChainBench/OpenChainBench/tree/main/harnesses/bridge-monitor +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/bridge-monitor prometheus: url: https://prometheus-production-9ffe.up.railway.app diff --git a/benchmarks/bridge-quote-latency.yml b/benchmarks/bridge-quote-latency.yml index 4a637a53..b3cd1400 100644 --- a/benchmarks/bridge-quote-latency.yml +++ b/benchmarks/bridge-quote-latency.yml @@ -43,15 +43,23 @@ methodology: - "Histogram buckets: 50, 100, 200, 500, 1000, 2000, 5000, 10000 ms." - "Failures (quote_failed, execution_failed, unsupported route) excluded from latency aggregates and counted toward success rate." +# Per-corridor leader rewrite deferred: this YAML does not yet declare +# `dimensions:` for `from_chain`/`to_chain` / corridor. The bridge-monitor +# metric carries those labels (see comment block below), so per-corridor +# placeholders can be wired in once the dimensions block is added in a +# separate code/data PR. The {{best_name}} / {{worst_name}} below +# resolve to the cross-corridor aggregate, which is honest because every +# bridge is queried on the same 4 USDC routes, but the text now makes +# the aggregation explicit rather than implying a single winning route. findings: - - "{{best_name}} currently leads quote latency at {{best_p50}} (p50, 24 h) across {{count}} measured bridges. The cohort spans direct protocols, aggregators and intent layers under the same query." + - "{{best_name}} currently leads the cross-corridor aggregate at {{best_p50}} (p50, 24 h) across {{count}} measured bridges. The cohort spans direct protocols, aggregators and intent layers under the same query, averaged over the 4 USDC routes the harness sweeps." - "{{name:relay}} returns {{p50:relay}} (p50, 24 h). Intent and relay layers usually clock lowest because the API surface is a thin price-discovery call against pre-quoted solvers, not a full route-search graph." - "{{name:lifi}} sits at {{p50:lifi}} (p50, 24 h). Aggregators do more work per call (route search across N underlying bridges, fee normalisation), which costs them a stable 100 to 400 ms versus pure relays." - "{{name:debridge}} clocks {{p50:debridge}} (p50, 24 h). Direct protocols quote a single canonical route, so latency reflects raw API plumbing rather than route-search complexity." - "Tail latency tells the integration story. p99 ({{p99:relay}} / {{p99:lifi}} / {{p99:debridge}}) is what dictates timeouts in production wallets; SDKs that wait for the slowest bridge degrade the whole UX when one provider has a bad minute." - - "{{worst_name}} trails at {{worst_p50}} (p50, 24 h). A 5x gap at p50 turns into a 10 to 20x gap at p99, which is where flaky integrations actually surface to end users." + - "{{worst_name}} trails the cross-corridor aggregate at {{worst_p50}} (p50, 24 h). A 5x gap at p50 turns into a 10 to 20x gap at p99. A bridge that lags overall can still be fastest on a specific corridor where it is closer to its solver pool, that per-corridor breakdown is on the roadmap." -source: https://github.com/OpenChainBench/OpenChainBench/tree/main/harnesses/bridge-monitor +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/bridge-monitor prometheus: url: https://prometheus-production-9ffe.up.railway.app @@ -60,7 +68,7 @@ prometheus: faq: - q: "Which cross-chain bridge has the lowest API latency?" - a: "{{best_name}} currently leads at {{best_p50}} (p50, 24 h) across {{count}} measured bridges. The leaderboard re-sorts every five minutes against fresh Prometheus samples, so the ranking reflects the last 24 hours of actual API performance from a single eu-west origin under the same USDC routes." + a: "{{best_name}} currently leads the cross-corridor aggregate at {{best_p50}} (p50, 24 h) across {{count}} measured bridges, averaged over the 4 USDC routes the harness sweeps. The leaderboard re-sorts every five minutes against fresh Prometheus samples, so the ranking reflects the last 24 hours of actual API performance from a single eu-west origin under the same routes. Per-corridor latency leaders can differ from this aggregate where one provider has a solver pool closer to a specific destination chain, that breakdown is on the roadmap once corridor dimensions are surfaced in the page UI." - q: "What is the difference between quote latency and bridge fill time?" a: "Quote latency is the wall-clock time the bridge API takes to return a price. Fill time is the wall-clock time the funds take to land on the destination chain after the user signs. They are decoupled: a bridge can quote in 100 ms and take 30 seconds to settle, or quote in 2 seconds and settle in 5 seconds. This benchmark measures the quote half because that is the part a wallet, an aggregator or an embedded swap UI feels first and tunes its UX around." - q: "Why does bridge quote latency matter for builders?" diff --git a/benchmarks/relay-link-revenue.yml b/benchmarks/bridge-revenue.yml similarity index 93% rename from benchmarks/relay-link-revenue.yml rename to benchmarks/bridge-revenue.yml index 604917bd..a9dd77d6 100644 --- a/benchmarks/relay-link-revenue.yml +++ b/benchmarks/bridge-revenue.yml @@ -1,11 +1,11 @@ # OpenChainBench. Bench № 028 -slug: relay-link-revenue +slug: bridge-revenue number: "028" -title: Relay.link implied protocol revenue -seo_title: "Relay.link revenue tracker 2026: implied margin USD per day, take rate bps, cross-chain swap volume" -seo_description: "Live tracker of Relay.link implied protocol revenue. Sum of (USD paid - USD received - gas - disclosed app fees) over all swaps in 24h / 7d / 30d windows. Upper bound on what Relay + solvers collectively keep. Methodology pre-registered, harness open source." -subtitle: Implied margin in USD that Relay.link plus its solver network collectively retain per swap, summed over rolling 24h / 7d / 30d windows. Upper bound, not exact. Read methodology before citing. +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) @@ -117,7 +117,12 @@ faq: - 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/OpenChainBench/OpenChainBench/tree/main/harnesses/relay-link-revenue +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 diff --git a/benchmarks/buyback-audit.md b/benchmarks/buyback-audit.md index 500605c0..89258898 100644 --- a/benchmarks/buyback-audit.md +++ b/benchmarks/buyback-audit.md @@ -28,7 +28,7 @@ v2 will add Jupiter Litterbox Trust (50% of Jupiter fees → on-market JUP buyba A ratio well below 1.0 is not automatically under-delivery. Two regimes produce it: 1. **Batched execution.** Sky's SBE accumulates surplus inside the Maker / Sky protocol and concentrates on-market SKY buys into irregular batches via Uniswap. A 7-day window taken mid-accumulation reads low; the long-run accrual to the destination address tracks closer to 1.0. -2. **Sustained under-funding.** The promised share overstates what is actually being routed to the buyback program. This shows up as a 30-day ratio that stays well below 1.0 across multiple snapshots — the cadence signal in (1) damps out, the structural signal does not. +2. **Sustained under-funding.** The promised share overstates what is actually being routed to the buyback program. This shows up as a 30-day ratio that stays well below 1.0 across multiple snapshots. The cadence signal in (1) damps out, the structural signal does not. The 7d vs 30d split is the cheapest tell for which regime is in play. Continuous executors (Hyperliquid AF) show 7d ≈ 30d; batched executors (Sky SBE) show 7d < 30d; structurally under-funded programs would show both windows low. diff --git a/benchmarks/buyback-audit.yml b/benchmarks/buyback-audit.yml index 662362e6..b6c7da30 100644 --- a/benchmarks/buyback-audit.yml +++ b/benchmarks/buyback-audit.yml @@ -91,7 +91,7 @@ faq: - q: "How often does this bench update?" a: "Every 5 minutes per protocol. The numbers are gauges over rolling 7-day and 30-day windows, so sub-5-minute resolution would not surface anything new, both the DeFiLlama promised side and the destination-wallet executed side are bounded by minutes-to-hours upstream cadences. The full window is recomputed on each scrape rather than carrying a running delta, so the ratio reacts immediately to DeFiLlama historical revisions or to a freshly indexed Etherscan transfer without needing a backfill pass." -source: https://github.com/OpenChainBench/OpenChainBench/tree/main/harnesses/buyback-audit +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/buyback-audit prometheus: window: 7d diff --git a/benchmarks/gas-estimation.yml b/benchmarks/gas-estimation.yml index 26976493..44b87c98 100644 --- a/benchmarks/gas-estimation.yml +++ b/benchmarks/gas-estimation.yml @@ -98,7 +98,7 @@ faq: - q: "Why is gas prediction so hard?" a: "EIP-1559 makes the base fee deterministic (it adjusts by ±12.5% per block based on the previous block's gas used), so every oracle agrees on base fee within a fraction of a gwei. The hard part is predicting the priority-fee distribution in the *next* block. Priority fees are set by users in response to mempool congestion, which shifts on swap activity, MEV bot deployments, NFT mints and DEX volume in a way no historical-lookback model can fully anticipate. The leaderboard surfaces which oracle's lookback / inference scheme tracks reality best per chain, sustained over 24 h." -source: https://github.com/OpenChainBench/OpenChainBench/tree/main/harnesses/gas-estimation +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/gas-estimation prometheus: window: 24h @@ -149,7 +149,7 @@ providers: series: gas_error_priority_gwei{oracle="blocknative", tier="p50"} - slug: publicnode-feehistory - name: PublicNode feeHistory + name: PublicNode tag: Thin wrapper over EIP-1559 reward percentiles formula: "Median over 24h of |PublicNode eth_feeHistory p50 reward − realized p50 priority fee| in gwei, computed per next-mined block on the active chain." queries: diff --git a/benchmarks/hyperliquid-frontends.yml b/benchmarks/hyperliquid-frontends.yml new file mode 100644 index 00000000..9c7eb94d --- /dev/null +++ b/benchmarks/hyperliquid-frontends.yml @@ -0,0 +1,965 @@ +# OpenChainBench. Bench № 030 + +slug: hyperliquid-frontends +number: "030" +title: Hyperliquid frontends user-cost benchmark +seo_title: "Hyperliquid frontends 2026: effective builder fee bps, $/user efficiency, fee discipline (Phantom, Axiom, OKX, GMGN, pvp.trade)" +seo_description: "Live benchmark of Hyperliquid frontends ranked by how much they extract from users, not by raw volume. Effective builder fee in bps (cap 100), D7 cohort retention per builder, and USD captured per active trader. Observational, on-chain attribution via builder code field, methodology public." +subtitle: How much each Hyperliquid frontend charges its users via the on-chain builder code. Effective fee in basis points (cap 100), summed over 24h. Lower = more aligned with traders. +category: Trading +status: live +metric: Effective builder fee (24h, bps) +unit: bps +higher_is_better: false + +disclaimer: | + Volume share dashboards already exist (ASXN, Coinmarketman, Flowscan, Allium). OCB leads with user cost instead. Effective fee is a volume weighted `builder_fee / notional` over the rolling 24h, so fee free promo windows pull it down. Native HL UI orders carry no builder code and are excluded; the bench tracks third party frontends that opt into the builder field. Companion metrics surface slippage, freshness, taker share, asset concentration. Local hl-node source, sub minute freshness. + +seo_intro: | + Every Hyperliquid order carries an optional on chain `builder` + field. Frontends like Phantom Perps, Axiom, MetaMask, pvp.trade, + Insilico, DefiApp, Dexari and Okto use this field to claim a + share of taker fees as their own rebate (capped at 100 basis + points on perps, 1000 on spot). The same field is the cleanest + user cost signal available. Dividing the total `builder_fee` + captured by the total notional routed gives the effective fee + each frontend charges its users on top of the base Hyperliquid + fee schedule. This benchmark publishes that ratio per builder + in sub minute freshness, reading the local hl-node fills stream + on the OCB infrastructure rather than the public daily CSV bucket. + The default chart shows the 24h volume weighted effective fee + in basis points. A tab switcher above the chart surfaces five + companion views computed from the same stream. Slippage proxy + is the per fill basis point gap between executed price and the + previous trade on the same asset, averaged over 24h, so a + frontend with a low fee but a wide routing path shows the real + user cost. Time since last fill is an outage detector, seconds + since the most recent attributed fill for that builder. Fills + per minute is the rolling activity rate. Taker share is the + fraction of fills that crossed the spread, separating aggressive + wallet style UIs from pro maker heavy terminals. Volume routed + is the headline number every other dashboard publishes, kept + here as a companion not the ranking driver. + +abstract: | + We compute a family of quality metrics per Hyperliquid builder + code by tailing the fills stream of a local hl-node operated + on OCB infrastructure. The node writes every block of fills + to `node_fills_by_block/hourly/YYYYMMDD/HH`, one JSON object + per line, with the `builder` address and `builderFee` on every + attributed fill. A Go harness tails these files every 30 seconds, + maintains a rolling 24h window in memory, and exposes a stable + Prometheus surface that this page consumes. The headline metric + is effective fee in basis points, `sum(builder_fee_usd) / + sum(notional_usd) * 10000` over the rolling 24h, volume weighted + across every fill attributed to the builder address. Five + companion metrics share the same stream. Price deviation bps is + the per fill `|fill_px - last_seen_px_same_asset| / last_seen_px + * 10000` averaged over the window, a proxy for execution quality. + Time since last fill in seconds catches frontends whose routing + pipeline is down. Fills per minute is the rolling activity rate. + Taker share is `crossed_fills / total_fills`. Top three assets + by routed notional per builder reveals routing strategy and + surfaces HIP-3 perps. The harness keeps a curated `builders.json` + mapping of EVM addresses to frontend slugs, currently 60 entries + covering wallets, terminals, social trading apps, bots, and + HIP-3 perps deployers, seeded from the public DefiLlama + dimension adapter registry plus behavioral cross reference of + the top unidentified addresses. Builder addresses with valid + fills outside this registry route through the same `builder` + field but stay invisible to this leaderboard until added by a + public PR. The bench does not place trades, + does not touch private keys, and does not depend on any internal + Mobula service. + +methodology: + - "Data source. A local hl-node operated on OCB infrastructure tails the Hyperliquid mainnet and writes every block of attributed fills to `node_fills_by_block/hourly/YYYYMMDD/HH`. Each line is one block, the `events` array carries one entry per fill, and every fill with a builder code includes the `builder` address plus `builderFee` in USDC. Sub minute freshness, no dependency on the public daily CSV bucket." + - "Cadence. The Go harness re reads the current and previous hourly files every 30 seconds, parses appended lines, and maintains a rolling 24h window in memory. Prometheus scrapes the metrics every 30 seconds via a Caddy reverse proxy with basic auth. End to end staleness from fill landing on chain to bench page render is typically under 60 seconds." + - "Effective fee calculation. For each builder over the rolling 24h window: `effective_fee_bps = sum(builder_fee_usd) / sum(notional_usd) * 10000`, where `notional_usd = px * sz` summed over every fill. Volume weighted, not flat averaged, so a builder routing one large trade at 1 bps and many tiny trades at 10 bps lands near the rate the biggest dollars actually paid." + - "USD per user efficiency. `fees_per_user_usd = sum(builder_fee_usd) / count(distinct user)` over the rolling 24h window. Surfaces predatory pricing more clearly than effective fee alone. A builder with 5 bps effective fee and $50 per user is extracting differently than one with the same 5 bps and $0.50 per user." + - "Price deviation, slippage proxy. For each new fill on asset A by builder B: `dev_bps = abs(fill_px - last_seen_px_on_A) / last_seen_px * 10000`, where `last_seen_px` is the most recent fill price on that asset across the whole cohort. Averaged per builder over the rolling 24h. Proxy for execution quality. A frontend that routes to less liquid venues or slower order paths shows higher deviation, a direct user cost on top of the fee column." + - "Outage detector. `last_fill_age_seconds = now - max(fill.time for fills in window)` per builder. During active hours the cohort baseline runs under one minute, so anything above a few minutes is a real anomaly. Catches frontends whose routing pipeline is down and frontends that quietly stopped operating but still have their builder code in circulation." + - "Taker share. Each fill has a boolean `crossed` field, true when the fill crossed the spread (taker), false when it rested in the book (maker). `taker_pct = crossed_fills / total_fills` per builder over the window. Separates aggressive instant execution UIs from pro maker heavy terminals." + - "Per asset top three. For each builder, the three assets with the highest notional volume in the rolling 24h. Cardinality bounded at the cohort size times 3 ranks (currently 60 builders × 3 = 180 series). The `asset` label rotates as concentration shifts. Reveals routing strategy and surfaces HIP-3 perps (xyz:, km:, cash: prefixes) that the older Hyperliquid frontends do not list." + - "Builder registry. `miniapps/hyperliquid-frontends-local/builders.json` is a hand curated `[{slug, name, address, valid_from, notes}]` array, currently 60 entries seeded from DefiLlama's open source dimension adapter registry plus a behavioral cross reference pass on the top unidentified addresses. Cohort spans wallets, pro terminals, social and PvP UIs, automated bots, signal apps, and HIP-3 deployers. New addresses ship via public PR." + - "Excluded by design. Native HL UI orders carry no builder code so they are not user attributable by this method. Roughly 95 percent of Hyperliquid fills land without a builder field. The 5 percent that do are the frontend ecosystem this bench measures." + - "Reproducibility. Harness source at `miniapps/hyperliquid-frontends-local/` in the mobula-api repo (Go). Anyone running their own hl-node can clone, point the `-data` flag at their `node_fills_by_block/hourly` root, run the binary against a Prometheus scraper, and reproduce these metrics. No private API keys, no internal Mobula service dependency." + - "Methodology versioning. Any change (registry additions, formula tweaks, exclusion thresholds, window sizes) ships as a public PR. Major changes run a parallel period publishing old and new metric series side by side under a `_v2` suffix until cutover." + +findings: + - "Effective fee spread is 10x across the cohort. Insilico and Axiom both clock 1.00 bps on the last 24h of attributed flow, the cheapest transparent flat in the set. MetaMask sits at 10.00 bps, the practical on-chain cap most frontends respect, making it the most extractive currently tracked. Phantom Perps lands at 5.22, in line with the wallet-integrated tier." + - "Fee alone is half the story. DefiApp executes tightest at 1.90 bps price deviation despite charging 5.02 bps in builder fee. Dexari deviates 25.83 bps, 13x the cohort median of about 3 bps, on top of its 8.91 bps fee. A user paying Dexari to route their flow is paying twice: once at the fee line, once at the fill price." + - "Volume and user-cost are uncorrelated. Phantom Perps routes 19.4M USD in 24h at 5.22 bps. Insilico routes 7.9M USD at 1.00 bps. MetaMask routes 5M USD at 10 bps. The leaderboard most HL dashboards publish (volume share) ranks Phantom first; the leaderboard users actually care about (what each routed dollar pays) ranks Insilico and Axiom on top." + - "Two outage signals are currently firing. Dexari's last attributed fill is 19 minutes stale against a cohort baseline under 1 minute. Okto has zero fills in 24h, fully inactive. The sub-minute hl-node freshness lets the bench distinguish 'no flow yet today' from 'frontend down', a gap the daily CSV pipeline could not close." + - "Taker share separates aggressive UIs from pro terminals. pvp.trade fills 100% taker, Phantom 99.4%, MetaMask 97.9%, all instant execution wallets that cross the spread by default. Insilico sits at 80.6%, the most maker heavy in the cohort, consistent with its workstation positioning where users place resting limit orders rather than market buying." + - "Asset concentration reveals routing strategy. Phantom is 51% BTC weighted (9.9M of 19.4M USD in 24h on BTC alone). DefiApp is the only frontend with HYPE as its #1 asset. Dexari and MetaMask both surface HIP-3 perps in their top three (xyz:TSLA, xyz:CL crude), a feature that does not exist on the older Hyperliquid frontends, useful for traders looking for stock perps exposure." + +faq: + - q: "Why does OpenChainBench not show Hyperliquid volume share?" + a: "Volume share is already published cleanly by ASXN HyperScreener, Coinmarketman HyperTracker, Flowscan, Allium, and at least three Dune dashboards. Republishing it would add no value. The unmeasured part of the frontend question is user cost: how much does each frontend take per dollar routed, and how stable is that take rate. That's what this bench answers. Raw yesterday's UTC notional per builder is still exposed as a secondary metric for readers who want it; it just doesn't drive the headline ranking." + - q: "How is the effective fee actually computed?" + a: "A local hl-node operated on OCB infrastructure tails the Hyperliquid mainnet and writes every block of attributed fills to disk. A Go harness re reads the current hourly file every 30 seconds. For each builder in our registry, we sum `builderFee` (USD) and `px * sz` (notional USD) over every fill in the rolling 24h window, then compute `sum(builder_fee) / sum(notional) * 10000`. That's the volume weighted effective fee in basis points, what a representative dollar of flow paid that frontend. End to end staleness from fill landing on chain to bench page render is typically under 60 seconds." + - q: "What's the on-chain cap?" + a: "Hyperliquid caps builder fees at 100 basis points on perps and 1000 basis points on spot, configured via the `ApproveBuilderFee` action signed by each user's main wallet. The bench's headline is denominated in basis points so 100 = the on-chain cap and 0 = fully waived rebate. Most frontends sit well below the cap; the bench surfaces who sits where." + - q: "Which frontends are tracked?" + a: "The registry currently tracks Phantom Perps, Axiom, pvp.trade, Insilico Terminal, DefiApp, MetaMask, Dexari and Okto. These eight account for roughly half of attributed Hyperliquid volume. Builder addresses were sourced from each frontend's public announcement, cross referenced against CoinMarketMan HyperTracker and Flowscan. Several hundred smaller builder addresses route the remaining attributed volume. They show in our local node stream and are scheduled for inclusion via public PR. The 95 percent of HL flow that uses no builder code (native UI orders, frontends that did not opt into the attribution system) sits outside this bench by design." + - q: "What about builder addresses outside the eight tracked frontends?" + a: "The local hl-node sees every builder address that appears on any Hyperliquid fill. Several hundred smaller builders route attributed volume below the eight tracked here. Those addresses are visible in our raw node stream but stay off the leaderboard until a public PR adds them to `builders.json` with a verified name. The coverage gap is honest, not hidden. Volume share dashboards from ASXN and CoinMarketMan list the full distribution if you need the exhaustive picture today." + - q: "What's fee discipline and why is it useful?" + a: "The 30-day standard deviation of the daily effective fee in basis points. Frontends running rotating promotions (fee-free week → normalize the next week → discount cycle) show high variance even when the 24h headline is low. Frontends with stable transparent pricing show low variance. The discipline column is what tells you whether the headline number you're reading is a stable rate or a snapshot mid-promo." + - q: "Does the bench measure native HL UI orders?" + a: "No. The native HL UI doesn't set a builder code, so its orders are not user-attributable by this method. Native HL's performance is covered separately by `/benchmarks/aggregator-head-lag` (data-feed latency) and the operational metrics Hyperliquid publishes themselves. This bench is specifically about third-party frontends that opt into the builder-code attribution system." + - q: "Can a frontend game the bench by routing wash trades?" + a: "Yes, the bench measures what the chain records. A frontend running wash volume to drive its own fee revenue (or to suppress its effective fee via 0-fee internal flow) shows up exactly as it appears on-chain. Mitigation is structural: the user-count column flags frontends with anomalously low unique-user counts relative to volume (a wash-heavy frontend has high $/user efficiency and low user count). A v1.1 methodology PR will add a sybil-cluster heuristic for repeat-counterparty fills." + - q: "What is price deviation and why publish it alongside the fee?" + a: "Per-fill basis-point gap between executed price and the most recent fill price on the same asset, averaged across the 24h window. Cleanest proxy for execution quality from a frontend that does not publish slippage stats. A frontend can charge a low builder fee and still cost users more if its routing fills wide of the recent print. Fee plus deviation is closer to true user cost than either alone." + - q: "What does the last-fill-age outage signal actually measure?" + a: "Seconds since the most recent attributed fill for that builder. The cohort baseline runs under one minute during active hours, so above a few minutes is a real anomaly. Catches two failure modes: a frontend whose routing pipeline is down (no new fills even when users try to trade), and a frontend that has quietly stopped operating but still has its builder code in circulation. Both were invisible on volume dashboards where zero volume reads as zero, not as broken." + +source: https://github.com/MobulaFi/mobula-monorepo/tree/dev/miniapps/hyperliquid-frontends-local + +prometheus: + window: 24h + expected_freshness_seconds: 5400 + +# Real metrics emitted by the hyperliquid-frontends harness. The Prom labels +# are kept narrow (just `builder`) so the cross-series queries below don't +# explode in cardinality. +# +# hl_frontend_effective_fee_bps_v2{builder} gauge (p50 headline) +# hl_frontend_d7_retention_pct{builder} gauge (p90 column) +# hl_frontend_d30_retention_pct{builder} gauge (also exposed) +# hl_frontend_fees_per_user_usd_v2{builder} gauge (p99 column) +# hl_frontend_volume_usd_24h{builder} gauge (secondary) +# hl_frontend_volume_share_pct{builder} gauge (secondary) +# hl_frontend_users_24h{builder} gauge +# hl_frontend_d7_cohort_size{builder} gauge (sample-size guard) +# hl_frontend_d30_cohort_size{builder} gauge +# hl_frontend_fills_total_24h_v2{builder} gauge +# hl_frontend_unattributed_share_pct gauge (coverage signal) +# hl_frontend_registry_age_seconds gauge (freshness signal) +# hl_frontend_csv_fetch_status_total{builder,code} counter (per HTTP code) +# +# Bench page column mapping. The p50/p90/p99 slots are repurposed since the +# unit is bps and there is no native percentile semantics: p50 = effective +# fee, p90 = D7 cohort retention, p99 = USD fees per unique trader. The +# repurposing is called out in the methodology + FAQ so readers don't +# misread the columns. Fee discipline (30d stddev) is computable in +# Prom via the MCP server but not surfaced in a column. + +providers: + - slug: phantom-perps + name: Phantom + tag: Phantom wallet's HL perps integration + formula: "Volume-weighted effective builder fee in basis points over the last complete UTC day, computed from Hyperliquid's public attributed-fills CSV for Phantom's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="phantom-perps"} + p90: hl_frontend_d7_retention_pct{builder="phantom-perps"} + p99: hl_frontend_fees_per_user_usd_v2{builder="phantom-perps"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="phantom-perps"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="phantom-perps"} + series: hl_frontend_effective_fee_bps_v2{builder="phantom-perps"} + + - slug: axiom + name: Axiom + tag: Solana-native terminal expanding to HL + formula: "Volume-weighted effective builder fee in basis points over the last complete UTC day, computed from Hyperliquid's public attributed-fills CSV for Axiom's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="axiom"} + p90: hl_frontend_d7_retention_pct{builder="axiom"} + p99: hl_frontend_fees_per_user_usd_v2{builder="axiom"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="axiom"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="axiom"} + series: hl_frontend_effective_fee_bps_v2{builder="axiom"} + + - slug: pvp-trade + name: pvp.trade + tag: Social PvP trading rooms + formula: "Volume-weighted effective builder fee in basis points over the last complete UTC day, computed from Hyperliquid's public attributed-fills CSV for pvp.trade's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="pvp-trade"} + p90: hl_frontend_d7_retention_pct{builder="pvp-trade"} + p99: hl_frontend_fees_per_user_usd_v2{builder="pvp-trade"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="pvp-trade"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="pvp-trade"} + series: hl_frontend_effective_fee_bps_v2{builder="pvp-trade"} + + - slug: insilico + name: Insilico + tag: Institutional trading workstation + formula: "Volume-weighted effective builder fee in basis points over the last complete UTC day, computed from Hyperliquid's public attributed-fills CSV for Insilico's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="insilico"} + p90: hl_frontend_d7_retention_pct{builder="insilico"} + p99: hl_frontend_fees_per_user_usd_v2{builder="insilico"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="insilico"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="insilico"} + series: hl_frontend_effective_fee_bps_v2{builder="insilico"} + + - slug: defiapp + name: Defiapp + tag: Defiapp HL frontend + formula: "Volume-weighted effective builder fee in basis points over the last complete UTC day, computed from Hyperliquid's public attributed-fills CSV for Defiapp's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="defiapp"} + p90: hl_frontend_d7_retention_pct{builder="defiapp"} + p99: hl_frontend_fees_per_user_usd_v2{builder="defiapp"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="defiapp"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="defiapp"} + series: hl_frontend_effective_fee_bps_v2{builder="defiapp"} + + - slug: metamask + name: MetaMask + tag: MetaMask wallet HL integration + formula: "Volume-weighted effective builder fee in basis points over the last complete UTC day, computed from Hyperliquid's public attributed-fills CSV for MetaMask's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="metamask"} + p90: hl_frontend_d7_retention_pct{builder="metamask"} + p99: hl_frontend_fees_per_user_usd_v2{builder="metamask"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="metamask"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="metamask"} + series: hl_frontend_effective_fee_bps_v2{builder="metamask"} + + - slug: dexari + name: Dexari + tag: Pro trading UI for HL + formula: "Volume-weighted effective builder fee in basis points over the last complete UTC day, computed from Hyperliquid's public attributed-fills CSV for Dexari's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="dexari"} + p90: hl_frontend_d7_retention_pct{builder="dexari"} + p99: hl_frontend_fees_per_user_usd_v2{builder="dexari"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="dexari"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="dexari"} + series: hl_frontend_effective_fee_bps_v2{builder="dexari"} + + - slug: okto + name: Okto + tag: Okto wallet HL integration + formula: "Volume-weighted effective builder fee in basis points over the last complete UTC day, computed from Hyperliquid's public attributed-fills CSV for Okto's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="okto"} + p90: hl_frontend_d7_retention_pct{builder="okto"} + p99: hl_frontend_fees_per_user_usd_v2{builder="okto"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="okto"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="okto"} + series: hl_frontend_effective_fee_bps_v2{builder="okto"} + - slug: trust-wallet + name: Trust Wallet + tag: Trust Wallet HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Trust Wallet's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="trust-wallet"} + p90: hl_frontend_d7_retention_pct{builder="trust-wallet"} + p99: hl_frontend_fees_per_user_usd_v2{builder="trust-wallet"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="trust-wallet"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="trust-wallet"} + series: hl_frontend_effective_fee_bps_v2{builder="trust-wallet"} + + - slug: sushi + name: Sushi + tag: Sushi HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Sushi's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="sushi"} + p90: hl_frontend_d7_retention_pct{builder="sushi"} + p99: hl_frontend_fees_per_user_usd_v2{builder="sushi"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="sushi"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="sushi"} + series: hl_frontend_effective_fee_bps_v2{builder="sushi"} + + - slug: dreamcash + name: Dreamcash + tag: Dreamcash HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Dreamcash's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="dreamcash"} + p90: hl_frontend_d7_retention_pct{builder="dreamcash"} + p99: hl_frontend_fees_per_user_usd_v2{builder="dreamcash"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="dreamcash"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="dreamcash"} + series: hl_frontend_effective_fee_bps_v2{builder="dreamcash"} + + - slug: based-app + name: Based + tag: Based HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Based's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="based-app"} + p90: hl_frontend_d7_retention_pct{builder="based-app"} + p99: hl_frontend_fees_per_user_usd_v2{builder="based-app"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="based-app"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="based-app"} + series: hl_frontend_effective_fee_bps_v2{builder="based-app"} + + - slug: blink + name: Blink + tag: Blink HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Blink's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="blink"} + p90: hl_frontend_d7_retention_pct{builder="blink"} + p99: hl_frontend_fees_per_user_usd_v2{builder="blink"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="blink"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="blink"} + series: hl_frontend_effective_fee_bps_v2{builder="blink"} + + - slug: perpmate + name: Perpmate + tag: Perpmate HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Perpmate's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="perpmate"} + p90: hl_frontend_d7_retention_pct{builder="perpmate"} + p99: hl_frontend_fees_per_user_usd_v2{builder="perpmate"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="perpmate"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="perpmate"} + series: hl_frontend_effective_fee_bps_v2{builder="perpmate"} + + - slug: arena + name: Arena + tag: Arena HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Arena's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="arena"} + p90: hl_frontend_d7_retention_pct{builder="arena"} + p99: hl_frontend_fees_per_user_usd_v2{builder="arena"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="arena"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="arena"} + series: hl_frontend_effective_fee_bps_v2{builder="arena"} + + - slug: minaraai + name: MinaraAI + tag: MinaraAI HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for MinaraAI's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="minaraai"} + p90: hl_frontend_d7_retention_pct{builder="minaraai"} + p99: hl_frontend_fees_per_user_usd_v2{builder="minaraai"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="minaraai"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="minaraai"} + series: hl_frontend_effective_fee_bps_v2{builder="minaraai"} + + - slug: apexliquid + name: ApexLiquid + tag: ApexLiquid HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for ApexLiquid's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="apexliquid"} + p90: hl_frontend_d7_retention_pct{builder="apexliquid"} + p99: hl_frontend_fees_per_user_usd_v2{builder="apexliquid"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="apexliquid"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="apexliquid"} + series: hl_frontend_effective_fee_bps_v2{builder="apexliquid"} + + - slug: coin98 + name: Coin98 + tag: Coin98 HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Coin98's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="coin98"} + p90: hl_frontend_d7_retention_pct{builder="coin98"} + p99: hl_frontend_fees_per_user_usd_v2{builder="coin98"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="coin98"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="coin98"} + series: hl_frontend_effective_fee_bps_v2{builder="coin98"} + + - slug: coinpilot + name: CoinPilot + tag: CoinPilot HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for CoinPilot's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="coinpilot"} + p90: hl_frontend_d7_retention_pct{builder="coinpilot"} + p99: hl_frontend_fees_per_user_usd_v2{builder="coinpilot"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="coinpilot"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="coinpilot"} + series: hl_frontend_effective_fee_bps_v2{builder="coinpilot"} + + - slug: echosync + name: Echosync + tag: Echosync HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Echosync's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="echosync"} + p90: hl_frontend_d7_retention_pct{builder="echosync"} + p99: hl_frontend_fees_per_user_usd_v2{builder="echosync"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="echosync"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="echosync"} + series: hl_frontend_effective_fee_bps_v2{builder="echosync"} + + - slug: fomo + name: FOMO + tag: FOMO HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for FOMO's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="fomo"} + p90: hl_frontend_d7_retention_pct{builder="fomo"} + p99: hl_frontend_fees_per_user_usd_v2{builder="fomo"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="fomo"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="fomo"} + series: hl_frontend_effective_fee_bps_v2{builder="fomo"} + + - slug: gemwallet + name: Gem Wallet + tag: Gem Wallet HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Gem Wallet's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="gemwallet"} + p90: hl_frontend_d7_retention_pct{builder="gemwallet"} + p99: hl_frontend_fees_per_user_usd_v2{builder="gemwallet"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="gemwallet"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="gemwallet"} + series: hl_frontend_effective_fee_bps_v2{builder="gemwallet"} + + - slug: gtr-trade + name: GTR Trade + tag: GTR Trade HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for GTR Trade's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="gtr-trade"} + p90: hl_frontend_d7_retention_pct{builder="gtr-trade"} + p99: hl_frontend_fees_per_user_usd_v2{builder="gtr-trade"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="gtr-trade"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="gtr-trade"} + series: hl_frontend_effective_fee_bps_v2{builder="gtr-trade"} + + - slug: hyprearn + name: Hyprearn + tag: Hyprearn HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Hyprearn's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="hyprearn"} + p90: hl_frontend_d7_retention_pct{builder="hyprearn"} + p99: hl_frontend_fees_per_user_usd_v2{builder="hyprearn"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="hyprearn"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="hyprearn"} + series: hl_frontend_effective_fee_bps_v2{builder="hyprearn"} + + - slug: legend-trade + name: Legend Trade + tag: Legend Trade HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Legend Trade's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="legend-trade"} + p90: hl_frontend_d7_retention_pct{builder="legend-trade"} + p99: hl_frontend_fees_per_user_usd_v2{builder="legend-trade"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="legend-trade"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="legend-trade"} + series: hl_frontend_effective_fee_bps_v2{builder="legend-trade"} + + - slug: katoshi + name: Katoshi + tag: Katoshi HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Katoshi's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="katoshi"} + p90: hl_frontend_d7_retention_pct{builder="katoshi"} + p99: hl_frontend_fees_per_user_usd_v2{builder="katoshi"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="katoshi"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="katoshi"} + series: hl_frontend_effective_fee_bps_v2{builder="katoshi"} + + - slug: metascalp + name: Metascalp + tag: Metascalp HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Metascalp's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="metascalp"} + p90: hl_frontend_d7_retention_pct{builder="metascalp"} + p99: hl_frontend_fees_per_user_usd_v2{builder="metascalp"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="metascalp"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="metascalp"} + series: hl_frontend_effective_fee_bps_v2{builder="metascalp"} + + - slug: moontrader + name: Moontrader + tag: Moontrader HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Moontrader's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="moontrader"} + p90: hl_frontend_d7_retention_pct{builder="moontrader"} + p99: hl_frontend_fees_per_user_usd_v2{builder="moontrader"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="moontrader"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="moontrader"} + series: hl_frontend_effective_fee_bps_v2{builder="moontrader"} + + - slug: onekey + name: OneKey + tag: OneKey HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for OneKey's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="onekey"} + p90: hl_frontend_d7_retention_pct{builder="onekey"} + p99: hl_frontend_fees_per_user_usd_v2{builder="onekey"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="onekey"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="onekey"} + series: hl_frontend_effective_fee_bps_v2{builder="onekey"} + + - slug: pear + name: Pear + tag: Pear HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Pear's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="pear"} + p90: hl_frontend_d7_retention_pct{builder="pear"} + p99: hl_frontend_fees_per_user_usd_v2{builder="pear"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="pear"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="pear"} + series: hl_frontend_effective_fee_bps_v2{builder="pear"} + + - slug: rabby + name: Rabby + tag: Rabby HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Rabby's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="rabby"} + p90: hl_frontend_d7_retention_pct{builder="rabby"} + p99: hl_frontend_fees_per_user_usd_v2{builder="rabby"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="rabby"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="rabby"} + series: hl_frontend_effective_fee_bps_v2{builder="rabby"} + + - slug: ranger-finance + name: Ranger Finance + tag: Ranger Finance HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Ranger Finance's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="ranger-finance"} + p90: hl_frontend_d7_retention_pct{builder="ranger-finance"} + p99: hl_frontend_fees_per_user_usd_v2{builder="ranger-finance"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="ranger-finance"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="ranger-finance"} + series: hl_frontend_effective_fee_bps_v2{builder="ranger-finance"} + + - slug: senpi + name: Senpi + tag: Senpi HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Senpi's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="senpi"} + p90: hl_frontend_d7_retention_pct{builder="senpi"} + p99: hl_frontend_fees_per_user_usd_v2{builder="senpi"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="senpi"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="senpi"} + series: hl_frontend_effective_fee_bps_v2{builder="senpi"} + + - slug: superx + name: SuperX + tag: SuperX HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for SuperX's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="superx"} + p90: hl_frontend_d7_retention_pct{builder="superx"} + p99: hl_frontend_fees_per_user_usd_v2{builder="superx"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="superx"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="superx"} + series: hl_frontend_effective_fee_bps_v2{builder="superx"} + + - slug: supurr + name: Supurr + tag: Supurr HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Supurr's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="supurr"} + p90: hl_frontend_d7_retention_pct{builder="supurr"} + p99: hl_frontend_fees_per_user_usd_v2{builder="supurr"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="supurr"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="supurr"} + series: hl_frontend_effective_fee_bps_v2{builder="supurr"} + + - slug: unigox + name: Unigox + tag: Unigox HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Unigox's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="unigox"} + p90: hl_frontend_d7_retention_pct{builder="unigox"} + p99: hl_frontend_fees_per_user_usd_v2{builder="unigox"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="unigox"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="unigox"} + series: hl_frontend_effective_fee_bps_v2{builder="unigox"} + + - slug: uxuy + name: UXUY + tag: UXUY HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for UXUY's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="uxuy"} + p90: hl_frontend_d7_retention_pct{builder="uxuy"} + p99: hl_frontend_fees_per_user_usd_v2{builder="uxuy"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="uxuy"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="uxuy"} + series: hl_frontend_effective_fee_bps_v2{builder="uxuy"} + + - slug: wunder + name: Wunder + tag: Wunder HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Wunder's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="wunder"} + p90: hl_frontend_d7_retention_pct{builder="wunder"} + p99: hl_frontend_fees_per_user_usd_v2{builder="wunder"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="wunder"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="wunder"} + series: hl_frontend_effective_fee_bps_v2{builder="wunder"} + + - slug: grider + name: Grider + tag: Grider HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Grider's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="grider"} + p90: hl_frontend_d7_retention_pct{builder="grider"} + p99: hl_frontend_fees_per_user_usd_v2{builder="grider"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="grider"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="grider"} + series: hl_frontend_effective_fee_bps_v2{builder="grider"} + + - slug: tradoor + name: Tradoor + tag: Tradoor HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Tradoor's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="tradoor"} + p90: hl_frontend_d7_retention_pct{builder="tradoor"} + p99: hl_frontend_fees_per_user_usd_v2{builder="tradoor"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="tradoor"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="tradoor"} + series: hl_frontend_effective_fee_bps_v2{builder="tradoor"} + + - slug: bullpenfi + name: BullpenFi + tag: BullpenFi HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for BullpenFi's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="bullpenfi"} + p90: hl_frontend_d7_retention_pct{builder="bullpenfi"} + p99: hl_frontend_fees_per_user_usd_v2{builder="bullpenfi"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="bullpenfi"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="bullpenfi"} + series: hl_frontend_effective_fee_bps_v2{builder="bullpenfi"} + + - slug: dexly-trade + name: Dexly Trade + tag: Dexly Trade HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Dexly Trade's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="dexly-trade"} + p90: hl_frontend_d7_retention_pct{builder="dexly-trade"} + p99: hl_frontend_fees_per_user_usd_v2{builder="dexly-trade"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="dexly-trade"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="dexly-trade"} + series: hl_frontend_effective_fee_bps_v2{builder="dexly-trade"} + + - slug: hyperdash + name: Hyperdash + tag: Hyperdash HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Hyperdash's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="hyperdash"} + p90: hl_frontend_d7_retention_pct{builder="hyperdash"} + p99: hl_frontend_fees_per_user_usd_v2{builder="hyperdash"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="hyperdash"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="hyperdash"} + series: hl_frontend_effective_fee_bps_v2{builder="hyperdash"} + + - slug: infinex + name: Infinex + tag: Infinex HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Infinex's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="infinex"} + p90: hl_frontend_d7_retention_pct{builder="infinex"} + p99: hl_frontend_fees_per_user_usd_v2{builder="infinex"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="infinex"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="infinex"} + series: hl_frontend_effective_fee_bps_v2{builder="infinex"} + + - slug: liminal + name: Liminal + tag: Liminal HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Liminal's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="liminal"} + p90: hl_frontend_d7_retention_pct{builder="liminal"} + p99: hl_frontend_fees_per_user_usd_v2{builder="liminal"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="liminal"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="liminal"} + series: hl_frontend_effective_fee_bps_v2{builder="liminal"} + + - slug: liquid-perps + name: Liquid Perps + tag: Liquid Perps HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Liquid Perps's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="liquid-perps"} + p90: hl_frontend_d7_retention_pct{builder="liquid-perps"} + p99: hl_frontend_fees_per_user_usd_v2{builder="liquid-perps"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="liquid-perps"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="liquid-perps"} + series: hl_frontend_effective_fee_bps_v2{builder="liquid-perps"} + + - slug: lit-trade + name: Lit Trade + tag: Lit Trade HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Lit Trade's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="lit-trade"} + p90: hl_frontend_d7_retention_pct{builder="lit-trade"} + p99: hl_frontend_fees_per_user_usd_v2{builder="lit-trade"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="lit-trade"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="lit-trade"} + series: hl_frontend_effective_fee_bps_v2{builder="lit-trade"} + + - slug: lootbase + name: Lootbase + tag: Lootbase HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Lootbase's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="lootbase"} + p90: hl_frontend_d7_retention_pct{builder="lootbase"} + p99: hl_frontend_fees_per_user_usd_v2{builder="lootbase"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="lootbase"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="lootbase"} + series: hl_frontend_effective_fee_bps_v2{builder="lootbase"} + + - slug: mass-dot-money + name: Mass.money + tag: Mass.money HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Mass.money's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="mass-dot-money"} + p90: hl_frontend_d7_retention_pct{builder="mass-dot-money"} + p99: hl_frontend_fees_per_user_usd_v2{builder="mass-dot-money"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="mass-dot-money"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="mass-dot-money"} + series: hl_frontend_effective_fee_bps_v2{builder="mass-dot-money"} + + - slug: moonbot + name: Moonbot + tag: Moonbot HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Moonbot's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="moonbot"} + p90: hl_frontend_d7_retention_pct{builder="moonbot"} + p99: hl_frontend_fees_per_user_usd_v2{builder="moonbot"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="moonbot"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="moonbot"} + series: hl_frontend_effective_fee_bps_v2{builder="moonbot"} + + - slug: rainbow + name: Rainbow + tag: Rainbow HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Rainbow's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="rainbow"} + p90: hl_frontend_d7_retention_pct{builder="rainbow"} + p99: hl_frontend_fees_per_user_usd_v2{builder="rainbow"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="rainbow"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="rainbow"} + series: hl_frontend_effective_fee_bps_v2{builder="rainbow"} + + - slug: supercexy + name: SuperCEXy + tag: SuperCEXy HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for SuperCEXy's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="supercexy"} + p90: hl_frontend_d7_retention_pct{builder="supercexy"} + p99: hl_frontend_fees_per_user_usd_v2{builder="supercexy"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="supercexy"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="supercexy"} + series: hl_frontend_effective_fee_bps_v2{builder="supercexy"} + + - slug: superstack + name: Superstack + tag: Superstack HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Superstack's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="superstack"} + p90: hl_frontend_d7_retention_pct{builder="superstack"} + p99: hl_frontend_fees_per_user_usd_v2{builder="superstack"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="superstack"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="superstack"} + series: hl_frontend_effective_fee_bps_v2{builder="superstack"} + + - slug: wallet-v + name: Wallet V + tag: Wallet V HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Wallet V's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="wallet-v"} + p90: hl_frontend_d7_retention_pct{builder="wallet-v"} + p99: hl_frontend_fees_per_user_usd_v2{builder="wallet-v"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="wallet-v"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="wallet-v"} + series: hl_frontend_effective_fee_bps_v2{builder="wallet-v"} + + - slug: xtrade-protocol + name: xTrade Protocol + tag: xTrade Protocol HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for xTrade Protocol's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="xtrade-protocol"} + p90: hl_frontend_d7_retention_pct{builder="xtrade-protocol"} + p99: hl_frontend_fees_per_user_usd_v2{builder="xtrade-protocol"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="xtrade-protocol"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="xtrade-protocol"} + series: hl_frontend_effective_fee_bps_v2{builder="xtrade-protocol"} + + - slug: taco-trade + name: Taco Trade + tag: Taco Trade HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Taco Trade's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="taco-trade"} + p90: hl_frontend_d7_retention_pct{builder="taco-trade"} + p99: hl_frontend_fees_per_user_usd_v2{builder="taco-trade"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="taco-trade"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="taco-trade"} + series: hl_frontend_effective_fee_bps_v2{builder="taco-trade"} + + - slug: silhouette + name: Silhouette + tag: Silhouette HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Silhouette's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="silhouette"} + p90: hl_frontend_d7_retention_pct{builder="silhouette"} + p99: hl_frontend_fees_per_user_usd_v2{builder="silhouette"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="silhouette"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="silhouette"} + series: hl_frontend_effective_fee_bps_v2{builder="silhouette"} + + - slug: tread-fi + name: Tread.fi + tag: Tread.fi HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Tread.fi's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="tread-fi"} + p90: hl_frontend_d7_retention_pct{builder="tread-fi"} + p99: hl_frontend_fees_per_user_usd_v2{builder="tread-fi"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="tread-fi"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="tread-fi"} + series: hl_frontend_effective_fee_bps_v2{builder="tread-fi"} + + - slug: flowbot + name: FlowBot + tag: FlowBot HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for FlowBot's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="flowbot"} + p90: hl_frontend_d7_retention_pct{builder="flowbot"} + p99: hl_frontend_fees_per_user_usd_v2{builder="flowbot"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="flowbot"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="flowbot"} + series: hl_frontend_effective_fee_bps_v2{builder="flowbot"} + + - slug: nautilus-trader + name: Nautilus Trader + tag: Nautilus Trader HL frontend + formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Nautilus Trader's builder address." + queries: + p50: hl_frontend_effective_fee_bps_v2{builder="nautilus-trader"} + p90: hl_frontend_d7_retention_pct{builder="nautilus-trader"} + p99: hl_frontend_fees_per_user_usd_v2{builder="nautilus-trader"} + mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="nautilus-trader"}[7d]) + success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) + sample_size: hl_frontend_fills_total_24h_v2{builder="nautilus-trader"} + series: hl_frontend_effective_fee_bps_v2{builder="nautilus-trader"} + +# Companion metric panels surfaced as a switchable mini leaderboard below +# the main p50/p90/p99 table. Each panel queries one Prom metric per +# provider (builder label) and re-ranks the cohort under its direction. +# All five metrics come from the local hl-node harness and update at the +# same sub-minute cadence as the main headline. +metric_panels: + - id: deviation + label: Slippage proxy + description: "Per fill, the basis-point gap between the executed price and the previous trade price on the same asset, averaged over the rolling 24h window. Lower is better. Frontends that route to less liquid venues or slower order paths show higher deviation, which is direct user cost on top of the fee column." + metric: hl_frontend_price_deviation_bps_v2 + unit: bps + higher_is_better: false + - id: outage + label: Time since last fill + description: "Seconds since this builder's most recent attributed fill landed on Hyperliquid. Cohort baseline runs under one minute during active hours; above a few minutes is a real anomaly. Catches frontends whose routing pipeline is down or quietly stopped operating." + metric: hl_frontend_last_fill_age_seconds_v2 + unit: s + higher_is_better: false + - id: activity + label: Fills per minute + description: "Activity rate over the rolling 24h window. Higher means more flow currently routes through this frontend. Combine with effective fee bps to read the operational intent: high activity + low fee = aligned, high activity + high fee = extractive." + metric: hl_frontend_fills_per_min_v2 + unit: count + higher_is_better: true + - id: taker + label: Taker share + description: "Fraction of fills that crossed the spread (paid the bid ask gap). Aggressive instant execution wallets skew taker heavy, pro terminals with limit order workflows skew maker heavy. Reveals the routing intent embedded in each frontend's UX." + metric: hl_frontend_taker_pct_v2 + unit: pct + higher_is_better: false + - id: volume + label: Volume routed + description: "Notional USD routed in the rolling 24h window. Headline volume metric most other HL dashboards publish. Here it is companion, not the headline, because volume is only loosely correlated with how aligned a frontend is with its users." + metric: hl_frontend_volume_usd_24h_v2 + unit: usd + higher_is_better: true diff --git a/benchmarks/l1-finality.md b/benchmarks/l1-finality.md index 57f62ca9..949b0925 100644 --- a/benchmarks/l1-finality.md +++ b/benchmarks/l1-finality.md @@ -59,7 +59,7 @@ This works for: **BNB, Avalanche, TON**. | TON | High (after SSE refactor) | tonapi `workchain=-1` SSE stream, ms-precise | | Cardano | Medium | 15-conf compromise between Coinbase 10 and Kraken 15. Academic k=2160 is theoretical; no actor uses it | | Litecoin | Medium | 12-conf post-April-2026 reorg; standard is evolving | -| TRON | Medium | CEX confirmation counts vary 19–30; we use the 19-block protocol minimum | +| TRON | Medium | CEX confirmation counts vary 19 to 30; we use the 19-block protocol minimum | | Monero | Lower | XMR delisted from major regulated CEXes since 2024; no canonical confirmation count today | ## Why we chose the depths we did diff --git a/benchmarks/l1-finality.yml b/benchmarks/l1-finality.yml index f29c78d7..fda0284b 100644 --- a/benchmarks/l1-finality.yml +++ b/benchmarks/l1-finality.yml @@ -128,7 +128,7 @@ findings: - "{{best_name}} currently leads the leaderboard at {{best_p50}} (p50, 24 h) across {{count}} measured L1s. {{worst_name}} trails at {{worst_p50}}." - "Hedera's Hashgraph aBFT is designed to finalize in 3-5 s but is excluded from the live leaderboard because the mirror node only exposes finalized blocks, wall-clock measurement requires the Block Node service (HIP-1056), still in private preview." -source: https://github.com/OpenChainBench/OpenChainBench/tree/main/harnesses/l1-finality +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/l1-finality prometheus: window: 24h @@ -150,7 +150,7 @@ prometheus: providers: - slug: bnb - name: BNB Smart Chain + name: BNB tag: Fast finality fork (BEP-126), measured via WS wall-clock formula: "Median wall-clock milliseconds between a BNB block first appearing as `latest` on WebSocket and crossing the BEP-126 finalized threshold, p50 over 24h." queries: @@ -163,7 +163,7 @@ providers: series: l1_finality_wallclock_lag_milliseconds{chain="bnb"} - slug: avalanche - name: Avalanche C-Chain + name: Avalanche tag: Snowman++ consensus, measured via WS wall-clock formula: "Median wall-clock milliseconds between a C-Chain block emitting as `latest` and as `finalized` on a persistent WebSocket, p50 over 24h." queries: diff --git a/benchmarks/l2-block-time.yml b/benchmarks/l2-block-time.yml index 57d3b387..7be2ad61 100644 --- a/benchmarks/l2-block-time.yml +++ b/benchmarks/l2-block-time.yml @@ -42,7 +42,7 @@ findings: - "{{name:taiko}} clocks {{p50:taiko}} on a based-rollup design where Ethereum L1 validators sequence the L2 directly, fundamentally different from the sequencer model used by every other chain in this leaderboard." - "{{count}} L2s measured live. Trailing the leaderboard: {{worst_name}} at {{worst_p50}} (p50)." -source: https://github.com/OpenChainBench/OpenChainBench/tree/main/harnesses/l2-block-time +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/l2-block-time prometheus: window: 24h diff --git a/benchmarks/metadata-coverage.yml b/benchmarks/metadata-coverage.yml index 5ac9d837..dfc09d12 100644 --- a/benchmarks/metadata-coverage.yml +++ b/benchmarks/metadata-coverage.yml @@ -24,10 +24,15 @@ seo_intro: | providers becomes visible to end users. The harness watches new launches on Solana, BNB Chain and Base via Mobula Pulse V2, then asks Mobula, Codex and Jupiter (Solana only) for the same token and - records which fields came back populated. Coverage is the share of - field checks that returned a value, computed as a rolling rate so - the leaderboard reflects what the API is doing this hour, not what - its catalogue claims to hold. + records which fields came back populated. Because Jupiter is + Solana-only by construction, the leaderboard is read per chain + rather than as a cross-chain aggregate that mechanically favours + the chain-restricted provider. On Solana the current leader is + {{best_name:chain:solana}} at {{best_p50:chain:solana}}; on BNB + Chain it is {{best_name:chain:bnb}} at {{best_p50:chain:bnb}}. + Coverage is the share of field checks that returned a value, + computed as a rolling rate so the leaderboard reflects what the + API is doing this hour, not what its catalogue claims to hold. abstract: | We benchmark how complete each aggregator's token-metadata response is for @@ -55,14 +60,15 @@ methodology: - "Jupiter is Solana-only. it appears with zero coverage on EVM chains by construction; the cross-chain headline excludes Jupiter on chains it does not support." findings: - - "{{best_name}} currently leads metadata coverage at {{best_p50}} (p50, 24 h) across {{count}} measured providers. The number is the share of (logo, description, twitter, website) field checks that returned a populated value on freshly-launched tokens." - - "{{name:mobula}} returns {{p50:mobula}} (p50, 24 h). Coverage on fresh tokens reflects how aggressively the provider's indexer pulls launchpad-side metadata at mint time rather than waiting for a curator to add it later." - - "{{name:codex}} clocks {{p50:codex}} (p50, 24 h). GraphQL `token` queries return a richer schema by design, so coverage is high on tokens that survive their first hour and lower on fresh launches still missing socials." - - "{{name:jupiter}} sits at {{p50:jupiter}} (p50, 24 h) on Solana, by far its strongest chain. Jupiter does not index EVM, so the cross-chain headline excludes Jupiter on chains it does not support." + - "On Solana, {{best_name:chain:solana}} leads metadata coverage at {{best_p50:chain:solana}} (p50, 24 h). The number is the share of (logo, description, twitter, website) field checks that returned a populated value on freshly-launched Solana tokens." + - "On BNB Chain, {{best_name:chain:bnb}} leads at {{best_p50:chain:bnb}} (p50, 24 h). BNB is the cleaner read on indexer quality across providers because Jupiter does not index EVM, so the leaderboard is decided between Mobula and Codex on the same footing." + - "{{name:mobula}} returns {{p50:mobula}} (p50, 24 h) on the unfiltered aggregate. Coverage on fresh tokens reflects how aggressively the provider's indexer pulls launchpad-side metadata at mint time rather than waiting for a curator to add it later." + - "{{name:codex}} clocks {{p50:codex}} (p50, 24 h) on the unfiltered aggregate. GraphQL `token` queries return a richer schema by design, so coverage is high on tokens that survive their first hour and lower on fresh launches still missing socials." + - "{{name:jupiter}} sits at {{p50:jupiter}} (p50, 24 h). Jupiter is Solana-only by design, so its number reflects Solana coverage exclusively, the cross-chain aggregate is not comparable to multi-chain providers and is reported per chain instead." - "Coverage drops sharply on the first hour after a launch. p99 ({{p99:mobula}} / {{p99:codex}} / {{p99:jupiter}}) is what a trading UI sees during a launch burst when dozens of new tokens land in the same minute." - - "{{worst_name}} trails at {{worst_p50}} (p50, 24 h). A 30-point gap on the leaderboard means roughly one in three field renders falls back to a placeholder when displaying a fresh-launch token." + - "{{worst_name:chain:solana}} trails on Solana at {{worst_p50:chain:solana}} (p50, 24 h). A 30-point gap on the leaderboard means roughly one in three field renders falls back to a placeholder when displaying a fresh-launch token." -source: https://github.com/OpenChainBench/OpenChainBench/tree/main/harnesses/metadata-coverage +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/metadata-coverage prometheus: window: 24h @@ -70,7 +76,7 @@ prometheus: faq: - q: "Which token metadata API has the best coverage for fresh launches?" - a: "{{best_name}} currently leads at {{best_p50}} (p50, 24 h) across {{count}} measured providers. The benchmark targets the hardest case for any aggregator, tokens minted in the last few minutes on Solana, BNB Chain or Base launchpads (pump.fun, Four.meme, Meteora DBC, Raydium CPMM and others), where catalogue size matters less than how fast the indexer reads off-chain metadata." + a: "The leaderboard is read per chain because one provider (Jupiter) is Solana-only and would mechanically dominate an unfiltered cross-chain aggregate. On Solana, {{best_name:chain:solana}} currently leads at {{best_p50:chain:solana}} (p50, 24 h). On BNB Chain, {{best_name:chain:bnb}} leads at {{best_p50:chain:bnb}} (p50, 24 h). The benchmark targets the hardest case for any aggregator, tokens minted in the last few minutes on Solana, BNB or Base launchpads (pump.fun, Four.meme, Meteora DBC, Raydium CPMM and others), where catalogue size matters less than how fast the indexer reads off-chain metadata." - q: "What does 'metadata coverage' actually measure?" a: "For every freshly-launched token the harness asks each provider for that token's metadata and records whether four canonical fields came back populated. Logo, description, twitter, website. Coverage is the share of those field checks that returned a value, summed across providers, chains and fields, then expressed as a percentage. A provider with 80% coverage returns four-fifths of the requested fields on average for fresh tokens; a provider with 50% leaves half the fields blank." - q: "Why do fresh tokens have low metadata coverage everywhere?" diff --git a/benchmarks/network-coverage.yml b/benchmarks/network-coverage.yml index 4e1468c5..1d7be504 100644 --- a/benchmarks/network-coverage.yml +++ b/benchmarks/network-coverage.yml @@ -3,8 +3,8 @@ slug: network-coverage number: "005" title: Onchain data API with most networks supported -seo_title: "Crypto data API with the most blockchains supported in 2026: Codex, GeckoTerminal, Mobula live" -seo_description: "Live blockchain coverage leaderboard. How many mainnets each onchain data API (Mobula, Codex, GeckoTerminal) officially supports right now, audited every six hours against their public network endpoints." +seo_title: "Crypto data API with the most blockchains supported in 2026: Mobula, Codex, GeckoTerminal, Covalent, CoinPaprika, CoinStats, Dune live" +seo_description: "Live blockchain coverage leaderboard. How many mainnets each onchain data API (Mobula, Codex, GeckoTerminal, Covalent/GoldRush, CoinPaprika, CoinStats, Dune) officially supports right now, audited every six hours against their public network endpoints." subtitle: Number of blockchains each major onchain data provider officially supports. category: Aggregators status: live @@ -32,19 +32,24 @@ seo_intro: | abstract: | We benchmark how many networks each major onchain data provider lists in its public "supported networks" endpoint. The harness fetches the - official listing every six hours from GeckoTerminal, Codex and Mobula, - deduplicates by chain id and counts. Mainnet only, testnets are - excluded because providers list them inconsistently and the comparison - is meant to reflect what a builder can integrate against in production. - Coverage breadth is one dimension of a data provider's product, not a - ranking on quality. Latency, freshness and metadata completeness are - measured separately by benches № 001 and № 004. + official listing every six hours from seven providers (GeckoTerminal, + Codex, Mobula, CoinPaprika, Covalent/GoldRush, CoinStats and Dune via + Sim API), deduplicates by chain id and counts. Mainnet only, + testnets are excluded because providers list them inconsistently and + the comparison is meant to reflect what a builder can integrate against + in production. Coverage breadth is one dimension of a data provider's + product, not a ranking on quality. Latency, freshness and metadata + completeness are measured separately by benches № 001 and № 004. methodology: - "Source: each provider's public supported-networks endpoint." - "GeckoTerminal: GET /api/v2/networks (paginated, no auth)." - "Codex: GraphQL `getNetworks` query at https://graph.codex.io/graphql with an official API key." - "Mobula: GET /api/1/blockchains with an Authorization API key." + - "CoinPaprika: GET /v1/contracts (no auth). Lists platforms supported for contract lookup." + - "Covalent / GoldRush: GET /v1/chains/ with a Bearer API key; testnets filtered via `is_testnet`." + - "CoinStats: GET /wallet/blockchains with X-API-KEY." + - "Dune (via Sim API): GET https://api.sim.dune.com/v1/evm/supported-chains (no auth). EVM only, mainnets filtered via the `mainnet` tag." - "Cadence: full refresh every 6 hours." - "Counting: a provider's network is counted once per unique chain id; mainnet only." - "Failures (network errors, rate limits, auth errors) leave the previous count in place and increment a fetch_errors counter. the page falls back to its last successful sample." @@ -56,7 +61,7 @@ findings: - "{{name:geckoterminal}} sits at {{p50:geckoterminal}} mainnets. GeckoTerminal indexes wherever CoinGecko already tracks tokens, so the count tracks closely with CoinGecko's DEX coverage rollout rather than a standalone listing decision." - "Raw chain count is one dimension of a data API's product. A provider with 200 chains and shallow per-chain depth can rank above one with 80 chains and full DEX, metadata and event coverage on each. Latency (bench № 001) and metadata completeness (bench № 004) measure the other dimensions." -source: https://github.com/OpenChainBench/OpenChainBench/tree/main/harnesses/network-coverage +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/network-coverage prometheus: window: 24h @@ -77,7 +82,9 @@ faq: a: "Any chain a provider lists as a queryable production environment, identified by a unique chain id. Ethereum mainnet, Solana mainnet, Base, Arbitrum, BNB Chain, Bitcoin and so on each count once regardless of how the provider labels them internally. Bridged variants of a token on different chains are not the same chain. The chain id is the deduplication key." # Real metrics exposed by the network-coverage harness: -# networks_supported_total{provider="geckoterminal"|"codex"|"mobula"} +# networks_supported_total{provider="geckoterminal"|"codex"|"mobula"| +# "coinpaprika"|"covalent"| +# "coinstats"|"dune"} # -> gauge, the unique-chain count from the latest successful refresh. # network_supported{provider, chain_id, slug, name} -> gauge (1 per # network; useful for diff queries on the site if we add a @@ -122,3 +129,55 @@ providers: success: clamp_max(networks_supported_total{provider="mobula"} > bool 0, 1) sample_size: networks_supported_total{provider="mobula"} series: networks_supported_total{provider="mobula"} + + - slug: coinpaprika + name: CoinPaprika + tag: Market data API + formula: "Count of platform slugs returned by CoinPaprika's public `/v1/contracts` endpoint, refreshed every 6 hours." + queries: + p50: networks_supported_total{provider="coinpaprika"} + p90: networks_supported_total{provider="coinpaprika"} + p99: networks_supported_total{provider="coinpaprika"} + mean: networks_supported_total{provider="coinpaprika"} + success: clamp_max(networks_supported_total{provider="coinpaprika"} > bool 0, 1) + sample_size: networks_supported_total{provider="coinpaprika"} + series: networks_supported_total{provider="coinpaprika"} + + - slug: covalent + name: Covalent + tag: GoldRush multichain data + formula: "Count of unique mainnet chains returned by Covalent's `/v1/chains/` endpoint with `is_testnet=false`, refreshed every 6 hours." + queries: + p50: networks_supported_total{provider="covalent"} + p90: networks_supported_total{provider="covalent"} + p99: networks_supported_total{provider="covalent"} + mean: networks_supported_total{provider="covalent"} + success: clamp_max(networks_supported_total{provider="covalent"} > bool 0, 1) + sample_size: networks_supported_total{provider="covalent"} + series: networks_supported_total{provider="covalent"} + + - slug: coinstats + name: CoinStats + tag: Portfolio + market data + formula: "Count of blockchains returned by CoinStats's `/wallet/blockchains` endpoint, refreshed every 6 hours." + queries: + p50: networks_supported_total{provider="coinstats"} + p90: networks_supported_total{provider="coinstats"} + p99: networks_supported_total{provider="coinstats"} + mean: networks_supported_total{provider="coinstats"} + success: clamp_max(networks_supported_total{provider="coinstats"} > bool 0, 1) + sample_size: networks_supported_total{provider="coinstats"} + series: networks_supported_total{provider="coinstats"} + + - slug: dune + name: Dune + tag: Onchain analytics + Sim API + formula: "Count of EVM chains tagged `mainnet` in Sim by Dune's `/v1/evm/supported-chains` endpoint, refreshed every 6 hours. EVM-only." + queries: + p50: networks_supported_total{provider="dune"} + p90: networks_supported_total{provider="dune"} + p99: networks_supported_total{provider="dune"} + mean: networks_supported_total{provider="dune"} + success: clamp_max(networks_supported_total{provider="dune"} > bool 0, 1) + sample_size: networks_supported_total{provider="dune"} + series: networks_supported_total{provider="dune"} diff --git a/benchmarks/network-fees.yml b/benchmarks/network-fees.yml new file mode 100644 index 00000000..6a679cd3 --- /dev/null +++ b/benchmarks/network-fees.yml @@ -0,0 +1,334 @@ +# OpenChainBench. Bench № 031 + +slug: network-fees +number: "031" +title: Current native transfer fee across L1 and L2 blockchains +seo_title: "Cheapest blockchain transaction fee 2026: L1 and L2 USD live (Ethereum, Arbitrum, Optimism, Base, zkSync, Solana, BNB, Avalanche)" +seo_description: "Live USD cost of a native-token transfer across 20 L1 and L2 chains. L1: Ethereum, Solana, BNB, Avalanche, TRON, Cardano, SUI, TON, Stellar, Litecoin, Monero. L2: Arbitrum, Optimism, Base, zkSync, Linea, Scroll, Blast, Mantle, Taiko. Slow/standard/fast tiers refreshed every 30s." +subtitle: "Current USD cost of moving the chain's native asset across 20 L1 and L2 chains, refreshed every 30 seconds." + +seo_intro: | + This page shows the live USD cost of sending the native token on every major Layer-1 blockchain. We sample the chain-native fee market (eth_feeHistory for EVM chains, getRecentPrioritizationFees for Solana, koios protocol params for Cardano, fee_stats for Stellar, fee_estimate for Monero, mempool oracles for Litecoin, getChainParameters for TRON, sui_getReferenceGasPrice for SUI, hardcoded typical observed for TON), convert to the chain's smallest unit, then multiply by the native-token USD price pulled from Mobula every 30 seconds. The result is a head-to-head comparison of what it actually costs an end user to move one unit of value on each chain, rather than a gas-price comparison in gwei or lamports which is meaningless across protocols. + +faq: + - q: "What does this benchmark measure?" + a: "The current USD cost of a single native-token transfer on each of the 11 tracked L1 chains. A native transfer is the simplest possible action: send the chain's main asset from one address to another. ETH on Ethereum, SOL on Solana, ADA on Cardano, XLM on Stellar, and so on. We do not measure ERC-20 transfers, DEX swaps, or contract calls." + - q: "Why USD instead of gas price?" + a: "Gas price in gwei (Ethereum) cannot be compared to lamports per CU (Solana) or stroops per operation (Stellar). The only honest cross-chain unit is the dollar cost of a user-facing action, computed at scrape time using a live USD price for each native token. Mobula's market API provides the prices we multiply by." + - q: "What do slow, standard, and fast tiers mean?" + a: "Tiers exist for chains with a priority market where users can pay more to get faster inclusion. Slow targets roughly the 25th percentile of recent priority bids, standard the 50th, fast the 90th. Chains with deterministic or near-deterministic fees (Cardano, Stellar, TON, TRON native transfer) emit a single tier because there is no priority market to bid into." + - q: "Why is the Solana fee so low?" + a: "Solana charges 5000 lamports per signature as a hard base, plus an optional priority fee priced in micro-lamports per compute unit. A simple SOL transfer uses around 200 compute units, so the priority component is typically dwarfed by the base. At current SOL prices the headline fee sits well under one cent for non-congested blocks." + - q: "Why is the Cardano fee always similar?" + a: "Cardano fees are deterministic. The protocol parameters (`min_fee_a` per byte, `min_fee_b` base) are set by governance and updated rarely; a standard ADA transfer is roughly 250 bytes, so the lovelace cost is essentially fixed until the next parameter vote. The USD figure on the leaderboard only moves because ADA's USD price moves." + - q: "How are L2 fees handled?" + a: "Not on this page. L2 fees are a separate measurement problem (rollup fee = L2 execution + L1 data posting, the latter being a non-trivial blob market). The L2 bench is in progress as a companion to this L1 page; once shipped, you'll be able to compare L1-fee vs L2-fee for any wallet decision in one click." + - q: "How often does the page refresh?" + a: "Every 30 seconds. The harness re-queries each chain's fee oracle and Mobula's price API every 30 seconds, so headline values are at most 30 seconds stale plus chain RPC latency (typically under a second)." + - q: "Why are some chains using single tier?" + a: "Cardano fees are protocol-deterministic. Stellar's base fee is 100 stroops per operation network-wide. TON's typical fee is a hardcoded conservative estimate because TON has no clean fee-estimate RPC. TRON native transfers consume bandwidth at the published rate per byte. None of these chains expose a priority market a user can bid into for a TRX, ADA, XLM or TON transfer, so emitting a single tier is more honest than fabricating three identical values." + +category: Blockchains +status: live +metric: Native transfer fee USD +unit: usd +higher_is_better: false + +abstract: | + Every 30 seconds we ask each L1 chain its current native-transfer fee + in the chain's smallest unit (wei, lamport, lovelace, stroop, sun, + MIST, nanoton, litoshi, atomic), multiply by the live USD price of + the chain's native token from Mobula's market API, and publish the + result. Chains with a priority market expose three tiers (slow / std + / fast) mapped to roughly the 25th, 50th and 90th percentile of the + recent fee distribution. Deterministic-fee chains emit a single tier. + The 11-chain list mirrors the L1 finality bench so users can compare + cost and speed head-to-head. + +methodology: + - "Refresh cadence: 30 s. One process samples all 11 chains in parallel goroutines." + - "Ethereum / BNB / Avalanche (EVM): `eth_feeHistory` over the last 4 blocks at percentiles [25, 50, 90]. Cost = (base_fee + reward_percentile) × 21_000 gas, mapped to slow/std/fast." + - "Solana: `getRecentPrioritizationFees` percentiles 25/50/90 of micro-lamports per CU × 200 CU + 5_000 lamport base. Empty-fees response collapses to single std tier at the 5_000 base." + - "TRON: `getChainParameters.getTransactionFee` (currently 1_000 SUN/byte) × 268 bytes typical native transfer." + - "Cardano: koios `epoch_params.min_fee_a / min_fee_b` × 250 bytes typical native transfer. Deterministic, refreshes only when on-chain parameters change." + - "Stellar: horizon `fee_stats.last_ledger_base_fee` × 1 operation. Single tier." + - "SUI: `suix_getReferenceGasPrice` × 2_000_000 gas budget (typical observed for `Coin::transfer`). Single std tier." + - "TON: hardcoded 0.005 TON, the typical observed wallet-v4 transfer. TON's fee model is BoC-emulation only; a clean fee-estimate RPC does not exist." + - "Litecoin: litecoinspace.org `/api/v1/fees/recommended` (hourFee / halfHourFee / fastestFee in litoshi/vByte) × 225 vBytes typical 1-in-1-out P2WPKH transfer." + - "Monero: monero-rpc `get_fee_estimate.fees[0..2]` × 1500 bytes typical 1-in-2-out RingCT transaction." + - "USD prices: `api.mobula.io/api/1/market/multi-data` polled every 30 s for all 11 native tokens in one call." + - "Failures: any upstream error leaves the previous gauge in place, increments `tx_fee_fetch_errors_total{chain, error_type}`, and sets `tx_fee_health{chain}=0`." + +findings: + - "{{best_name}} is the cheapest L1 native transfer at {{best_p50}} (std, 24h)." + - "{{name:ethereum}} sits at {{p50:ethereum}} (std, 24h), the most expensive L1 transfer on this leaderboard during normal congestion." + - "{{name:bnb}}, {{name:avalanche}}, {{name:stellar}}, {{name:solana}}, {{name:litecoin}} cluster near or below one cent for a standard transfer." + - "{{name:cardano}}, {{name:tron}}, {{name:monero}} use deterministic or near-deterministic fee models; USD movement on the leaderboard reflects native-token price movement, not network congestion." + - "USD costs are computed at scrape time. A 10% intraday move in the native token's USD price moves the headline by 10% even if the chain-native fee is flat. We surface that intentionally — the dollar cost is what a user actually pays." + +source: https://github.com/MobulaFi/mobula-monorepo/tree/main/miniapps/transaction-fee + +prometheus: + window: 24h + +# Real metrics exposed by the transaction-fee harness: +# tx_fee_native_transfer_usd{chain, tier} gauge (USD) +# tx_fee_native_transfer_native{chain, tier} gauge (smallest unit) +# tx_fee_gas_price_gwei{chain, tier} gauge (EVM only) +# tx_fee_native_token_price_usd{chain} gauge (USD per token) +# tx_fee_last_refresh_timestamp_seconds{chain} gauge (unix s) +# tx_fee_fetch_errors_total{chain, error_type} counter +# tx_fee_health{chain} gauge (0|1) + +providers: + - slug: ethereum + name: Ethereum + layer: l1 + tag: EIP-1559 fee market, 21000 gas × (base + p50 priority) + formula: "Median USD cost of a native ETH transfer over 24h: (base_fee + p50 priority_fee) × 21000 gas × ETH USD price." + queries: + p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="ethereum",tier="std"}[24h]) + p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="ethereum",tier="fast"}[24h]) + p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="ethereum",tier="fast"}[24h]) + mean: avg_over_time(tx_fee_native_transfer_usd{chain="ethereum",tier="std"}[24h]) + success: avg_over_time(tx_fee_health{chain="ethereum"}[24h]) + series: tx_fee_native_transfer_usd{chain="ethereum",tier="std"} + + - slug: bnb + name: BNB Chain + layer: l1 + tag: EIP-1559 fee market, 21000 gas + formula: "Median USD cost of a native BNB transfer over 24h." + queries: + p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="bnb",tier="std"}[24h]) + p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="bnb",tier="fast"}[24h]) + p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="bnb",tier="fast"}[24h]) + mean: avg_over_time(tx_fee_native_transfer_usd{chain="bnb",tier="std"}[24h]) + success: avg_over_time(tx_fee_health{chain="bnb"}[24h]) + series: tx_fee_native_transfer_usd{chain="bnb",tier="std"} + + - slug: avalanche + name: Avalanche + layer: l1 + tag: C-Chain EIP-1559 fee market, 21000 gas + formula: "Median USD cost of a native AVAX transfer over 24h." + queries: + p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="avalanche",tier="std"}[24h]) + p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="avalanche",tier="fast"}[24h]) + p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="avalanche",tier="fast"}[24h]) + mean: avg_over_time(tx_fee_native_transfer_usd{chain="avalanche",tier="std"}[24h]) + success: avg_over_time(tx_fee_health{chain="avalanche"}[24h]) + series: tx_fee_native_transfer_usd{chain="avalanche",tier="std"} + + - slug: solana + name: Solana + layer: l1 + tag: 5000 lamport base + getRecentPrioritizationFees × 200 CU + formula: "Median USD cost of a native SOL transfer over 24h: 5000 lamports + p50 priority × 200 CU × SOL USD price." + queries: + p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="solana",tier="std"}[24h]) + p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="solana",tier="fast"}[24h]) + p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="solana",tier="fast"}[24h]) + mean: avg_over_time(tx_fee_native_transfer_usd{chain="solana",tier="std"}[24h]) + success: avg_over_time(tx_fee_health{chain="solana"}[24h]) + series: tx_fee_native_transfer_usd{chain="solana",tier="std"} + + - slug: tron + name: TRON + layer: l1 + tag: Bandwidth model, 268 bytes × getTransactionFee SUN/byte + formula: "Deterministic USD cost of a native TRX transfer using the bandwidth-rate parameter × 268 bytes typical transfer × TRX USD price." + queries: + p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="tron",tier="single"}[24h]) + p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="tron",tier="single"}[24h]) + p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="tron",tier="single"}[24h]) + mean: avg_over_time(tx_fee_native_transfer_usd{chain="tron",tier="single"}[24h]) + success: avg_over_time(tx_fee_health{chain="tron"}[24h]) + series: tx_fee_native_transfer_usd{chain="tron",tier="single"} + + - slug: cardano + name: Cardano + layer: l1 + tag: min_fee_b + size × min_fee_a, deterministic + formula: "Protocol-deterministic ADA transfer cost: koios live min_fee_a/b × 250 bytes typical × ADA USD price." + queries: + p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="cardano",tier="single"}[24h]) + p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="cardano",tier="single"}[24h]) + p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="cardano",tier="single"}[24h]) + mean: avg_over_time(tx_fee_native_transfer_usd{chain="cardano",tier="single"}[24h]) + success: avg_over_time(tx_fee_health{chain="cardano"}[24h]) + series: tx_fee_native_transfer_usd{chain="cardano",tier="single"} + + - slug: sui + name: Sui + layer: l1 + tag: suix_getReferenceGasPrice × 2_000_000 gas budget + formula: "USD cost of a native SUI transfer: live reference gas price × 2M gas budget × SUI USD price." + queries: + p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="sui",tier="std"}[24h]) + p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="sui",tier="std"}[24h]) + p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="sui",tier="std"}[24h]) + mean: avg_over_time(tx_fee_native_transfer_usd{chain="sui",tier="std"}[24h]) + success: avg_over_time(tx_fee_health{chain="sui"}[24h]) + series: tx_fee_native_transfer_usd{chain="sui",tier="std"} + + - slug: ton + name: TON + layer: l1 + tag: Hardcoded 0.005 TON typical wallet-v4 transfer + formula: "Conservative typical-observed cost of a TON wallet transfer × TON USD price. TON has no fee-estimate RPC; this is the observed median." + queries: + p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="ton",tier="single"}[24h]) + p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="ton",tier="single"}[24h]) + p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="ton",tier="single"}[24h]) + mean: avg_over_time(tx_fee_native_transfer_usd{chain="ton",tier="single"}[24h]) + success: avg_over_time(tx_fee_health{chain="ton"}[24h]) + series: tx_fee_native_transfer_usd{chain="ton",tier="single"} + + - slug: stellar + name: Stellar + layer: l1 + tag: 100 stroops base per operation, surge during congestion + formula: "Live horizon fee_stats.last_ledger_base_fee × 1 op × XLM USD price." + queries: + p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="stellar",tier="single"}[24h]) + p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="stellar",tier="single"}[24h]) + p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="stellar",tier="single"}[24h]) + mean: avg_over_time(tx_fee_native_transfer_usd{chain="stellar",tier="single"}[24h]) + success: avg_over_time(tx_fee_health{chain="stellar"}[24h]) + series: tx_fee_native_transfer_usd{chain="stellar",tier="single"} + + - slug: litecoin + name: Litecoin + layer: l1 + tag: litecoinspace fee oracle × 225 vBytes + formula: "Mempool-based litoshi/vByte × 225 vBytes typical P2WPKH transfer × LTC USD price." + queries: + p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="litecoin",tier="std"}[24h]) + p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="litecoin",tier="fast"}[24h]) + p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="litecoin",tier="fast"}[24h]) + mean: avg_over_time(tx_fee_native_transfer_usd{chain="litecoin",tier="std"}[24h]) + success: avg_over_time(tx_fee_health{chain="litecoin"}[24h]) + series: tx_fee_native_transfer_usd{chain="litecoin",tier="std"} + + - slug: monero + name: Monero + layer: l1 + tag: get_fee_estimate × 1500 bytes RingCT + formula: "Monero-rpc fee per byte × 1500 bytes typical 1-in-2-out RingCT × XMR USD price." + queries: + p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="monero",tier="std"}[24h]) + p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="monero",tier="fast"}[24h]) + p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="monero",tier="fast"}[24h]) + mean: avg_over_time(tx_fee_native_transfer_usd{chain="monero",tier="std"}[24h]) + success: avg_over_time(tx_fee_health{chain="monero"}[24h]) + series: tx_fee_native_transfer_usd{chain="monero",tier="std"} + + - slug: arbitrum + name: Arbitrum One + layer: l2 + tag: "Optimistic rollup (Nitro), 21000 gas" + formula: "Median USD cost of a native ETH transfer on Arbitrum One over 24h. L2 execution cost only; L1 data posting fee excluded (Phase 2)." + queries: + p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="arbitrum",tier="std"}[24h]) + p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="arbitrum",tier="fast"}[24h]) + p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="arbitrum",tier="fast"}[24h]) + mean: avg_over_time(tx_fee_native_transfer_usd{chain="arbitrum",tier="std"}[24h]) + success: avg_over_time(tx_fee_health{chain="arbitrum"}[24h]) + series: tx_fee_native_transfer_usd{chain="arbitrum",tier="std"} + - slug: optimism + name: Optimism + layer: l2 + tag: "OP Stack optimistic rollup, 21000 gas" + formula: "Median USD cost of a native ETH transfer on Optimism over 24h. L2 execution cost only; L1 data posting fee excluded (Phase 2)." + queries: + p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="optimism",tier="std"}[24h]) + p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="optimism",tier="fast"}[24h]) + p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="optimism",tier="fast"}[24h]) + mean: avg_over_time(tx_fee_native_transfer_usd{chain="optimism",tier="std"}[24h]) + success: avg_over_time(tx_fee_health{chain="optimism"}[24h]) + series: tx_fee_native_transfer_usd{chain="optimism",tier="std"} + - slug: base + name: Base + layer: l2 + tag: "OP Stack optimistic rollup (Coinbase), 21000 gas" + formula: "Median USD cost of a native ETH transfer on Base over 24h. L2 execution cost only; L1 data posting fee excluded (Phase 2)." + queries: + p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="base",tier="std"}[24h]) + p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="base",tier="fast"}[24h]) + p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="base",tier="fast"}[24h]) + mean: avg_over_time(tx_fee_native_transfer_usd{chain="base",tier="std"}[24h]) + success: avg_over_time(tx_fee_health{chain="base"}[24h]) + series: tx_fee_native_transfer_usd{chain="base",tier="std"} + - slug: zksync + name: zkSync Era + layer: l2 + tag: "ZK rollup, 21000 gas" + formula: "Median USD cost of a native ETH transfer on zkSync Era over 24h. L2 execution cost only; L1 data posting fee excluded (Phase 2)." + queries: + p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="zksync",tier="std"}[24h]) + p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="zksync",tier="fast"}[24h]) + p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="zksync",tier="fast"}[24h]) + mean: avg_over_time(tx_fee_native_transfer_usd{chain="zksync",tier="std"}[24h]) + success: avg_over_time(tx_fee_health{chain="zksync"}[24h]) + series: tx_fee_native_transfer_usd{chain="zksync",tier="std"} + - slug: linea + name: Linea + layer: l2 + tag: "ZK rollup (Stark), 21000 gas" + formula: "Median USD cost of a native ETH transfer on Linea over 24h. L2 execution cost only; L1 data posting fee excluded (Phase 2)." + queries: + p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="linea",tier="std"}[24h]) + p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="linea",tier="fast"}[24h]) + p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="linea",tier="fast"}[24h]) + mean: avg_over_time(tx_fee_native_transfer_usd{chain="linea",tier="std"}[24h]) + success: avg_over_time(tx_fee_health{chain="linea"}[24h]) + series: tx_fee_native_transfer_usd{chain="linea",tier="std"} + - slug: scroll + name: Scroll + layer: l2 + tag: "zkEVM rollup, 21000 gas" + formula: "Median USD cost of a native ETH transfer on Scroll over 24h. L2 execution cost only; L1 data posting fee excluded (Phase 2)." + queries: + p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="scroll",tier="std"}[24h]) + p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="scroll",tier="fast"}[24h]) + p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="scroll",tier="fast"}[24h]) + mean: avg_over_time(tx_fee_native_transfer_usd{chain="scroll",tier="std"}[24h]) + success: avg_over_time(tx_fee_health{chain="scroll"}[24h]) + series: tx_fee_native_transfer_usd{chain="scroll",tier="std"} + - slug: blast + name: Blast + layer: l2 + tag: "OP Stack optimistic rollup (Blast), 21000 gas" + formula: "Median USD cost of a native ETH transfer on Blast over 24h. L2 execution cost only; L1 data posting fee excluded (Phase 2)." + queries: + p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="blast",tier="std"}[24h]) + p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="blast",tier="fast"}[24h]) + p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="blast",tier="fast"}[24h]) + mean: avg_over_time(tx_fee_native_transfer_usd{chain="blast",tier="std"}[24h]) + success: avg_over_time(tx_fee_health{chain="blast"}[24h]) + series: tx_fee_native_transfer_usd{chain="blast",tier="std"} + - slug: mantle + name: Mantle + layer: l2 + tag: "OP Stack optimistic rollup (Mantle), 21000 gas" + formula: "Median USD cost of a native ETH transfer on Mantle over 24h. L2 execution cost only; L1 data posting fee excluded (Phase 2)." + queries: + p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="mantle",tier="std"}[24h]) + p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="mantle",tier="fast"}[24h]) + p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="mantle",tier="fast"}[24h]) + mean: avg_over_time(tx_fee_native_transfer_usd{chain="mantle",tier="std"}[24h]) + success: avg_over_time(tx_fee_health{chain="mantle"}[24h]) + series: tx_fee_native_transfer_usd{chain="mantle",tier="std"} + - slug: taiko + name: Taiko + layer: l2 + tag: "Based rollup, 21000 gas" + formula: "Median USD cost of a native ETH transfer on Taiko over 24h. L2 execution cost only; L1 data posting fee excluded (Phase 2)." + queries: + p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="taiko",tier="std"}[24h]) + p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="taiko",tier="fast"}[24h]) + p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="taiko",tier="fast"}[24h]) + mean: avg_over_time(tx_fee_native_transfer_usd{chain="taiko",tier="std"}[24h]) + success: avg_over_time(tx_fee_health{chain="taiko"}[24h]) + series: tx_fee_native_transfer_usd{chain="taiko",tier="std"} diff --git a/benchmarks/oracle-deviation.yml b/benchmarks/oracle-deviation.yml index 9942131c..07c91167 100644 --- a/benchmarks/oracle-deviation.yml +++ b/benchmarks/oracle-deviation.yml @@ -95,7 +95,7 @@ faq: - q: "How often is each oracle polled and why 30 s?" a: "Every (source, pair) is polled every 30 seconds. The cadence is bounded below by Chainlink's update mechanics, the on-chain feed only refreshes on deviation or heartbeat, so polling faster than the feed updates returns the same value and wastes RPC calls. 30 s is fast enough to catch every Chainlink round within ~half a heartbeat of its on-chain landing while keeping total request volume at 80 req/min across all 4 sources × 10 pairs (well under every free-tier ceiling). Pyth Hermes, Binance and Coinbase tickers refresh continuously so 30 s polling captures them at full freshness." -source: https://github.com/OpenChainBench/OpenChainBench/tree/main/harnesses/oracle-deviation +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/oracle-deviation prometheus: window: 24h diff --git a/benchmarks/perp-fees.yml b/benchmarks/perp-fees.yml index 9c43e260..2dd5f364 100644 --- a/benchmarks/perp-fees.yml +++ b/benchmarks/perp-fees.yml @@ -59,7 +59,7 @@ findings: - "GMX position fee is variable: 4 bps on positive impact opens, 6 bps on negative impact. We report the negative branch (conservative upper bound). {{name:gmx}} clocks {{p50:gmx}} all-in (p50, 24 h)." - "{{worst_name}} trails at {{worst_p50}} all-in (p50, 24 h). The leaderboard reorders intra-day with spread volatility, so a venue cheap on the rack rate can still trail when its book thins out at a $1000 buy." -source: https://github.com/OpenChainBench/OpenChainBench/tree/main/harnesses/perp-fees +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/perp-fees prometheus: window: 24h diff --git a/benchmarks/rpc-capabilities.yml b/benchmarks/rpc-capabilities.yml index 70f2f578..5a305519 100644 --- a/benchmarks/rpc-capabilities.yml +++ b/benchmarks/rpc-capabilities.yml @@ -22,19 +22,25 @@ seo_intro: | budget. We probe `eth_blockNumber` every 15 seconds against 15 audited providers across 10 EVM chains. PublicNode, dRPC, 1RPC, MeowRPC, Tenderly Gateway, Nodies (POKT), Lava Network, Merkle, - Flashbots Protect, Cloudflare and the chain-official endpoints - (Coinbase's `mainnet.base.org`, Binance's `bsc-dataseed1`, Ava - Labs' `api.avax.network`, Arbitrum Foundation's `arb1.arbitrum.io`, - Optimism Foundation's `mainnet.optimism.io`). Chains covered. - Ethereum (9 providers), Arbitrum (8), Base (6), Optimism (6), - Avalanche (6), BNB (5), Polygon (5), Linea + Scroll + Mantle (4 - each). Use the chain tab above to filter the leaderboard. - Latency is the headline number; the leaderboard also exposes - call-result classification (`ok` vs `http_err` vs `jsonrpc_err` vs - `stale` vs `timeout`, the Cloudflare-eth trap where a 200 OK hides - a JSON-RPC error field) and archive-depth support so a fast - endpoint that returned a state-pruned response at block - (head, 5M) is not silently treated as a viable archive source. + Flashbots Protect, Cloudflare and 5 chain-official foundation + endpoints (Base, Binance BSC, Avalanche, Arbitrum, Optimism). + Chains covered. Ethereum (9 providers), Arbitrum (8), Base (6), + Optimism (6), Avalanche (6), BNB (5), Polygon (5), Linea + Scroll + Mantle (4 + each). The headline question is per chain, not cross-chain. Five + providers are single-chain by design (the foundation endpoints + above), so a cross-chain aggregate mechanically rewards whichever + single-chain endpoint happens to be fastest on its one chain. + Current per-chain leaders. Ethereum {{best_name:chain:ethereum}} + at {{best_p50:chain:ethereum}}, Base {{best_name:chain:base}} at + {{best_p50:chain:base}}, BNB {{best_name:chain:bnb}} at + {{best_p50:chain:bnb}}, Arbitrum {{best_name:chain:arbitrum}} at + {{best_p50:chain:arbitrum}}. Use the chain tab above to filter the + leaderboard. Latency is the headline; the leaderboard also exposes + call-result classification (`ok` vs `http_err` vs `jsonrpc_err` vs `stale` vs + `timeout`, the Cloudflare-eth trap where a 200 OK hides a JSON-RPC + error field) and archive-depth support so a fast endpoint that + returned a state-pruned response at block (head, 5M) is not + silently treated as a viable archive source. abstract: | We measure the round-trip latency of a single, identical RPC call @@ -54,7 +60,7 @@ abstract: | as the fastest. methodology: - - "Cadence: every 15 seconds per (provider, chain) pair. Single eu-west region; multi-region requires running additional monitor instances." + - "Cadence: every 15 seconds per (provider, chain) pair, from each of 3 Railway replicas (us-east Virginia, eu-west Amsterdam, sgp Singapore). The harness reads `$RAILWAY_REPLICA_REGION` at boot, normalizes it to the canonical 3-region set, and stamps a `region` label on every emitted metric. Headline p50/p90/p99 aggregate across all 3 regions via Prometheus `avg(quantile_over_time(...))`; per-region breakdowns are available on the time-series chart." - "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 `time.Now()` delta around the round-trip, in milliseconds. Exposed as both a gauge (`rpc_latency_milliseconds`) and a histogram (`rpc_latency_milliseconds_histogram`) with buckets 50 ms → 10 s, so p50/p90/p99 are computed via Prometheus `histogram_quantile` / `quantile_over_time`." - "Call-result classification: `ok` (HTTP 200 + non-empty result), `http_err` (status ≠ 200 or transport failure), `jsonrpc_err` (HTTP 200 with an `error` field, the Cloudflare-eth trap), `stale` (returned block more than 20 behind the cross-provider tip for that chain), `timeout`. Counter `rpc_call_total{result}` powers the reliability leaderboard." @@ -63,16 +69,18 @@ methodology: - "Excluded by design: Ankr (key-gated), gateway.fm (29 req/IP budget too tight for 15s polling), LlamaRPC + BlockPI + OmniaTech (Cloudflare 521 region-blocked), Alchemy demo (rate-limited dead), NodeReal + GetBlock + Chainstack (key-gated). Merkle on Ethereum + Lava on non-ETH/Arb + MeowRPC on non-ETH/Arb all excluded chain-by-chain. Every (provider, chain) was live-verified no-key before inclusion." findings: - - "{{best_name}} currently leads at {{best_p50}} (p50, 24 h) on the active chain tab, across {{count}} measured RPC providers. The number is the round-trip latency of a single `eth_blockNumber` call from eu-west, the cheapest possible read on any Ethereum-shaped RPC." - - "{{name:publicnode}} returns {{p50:publicnode}}. PublicNode (operated by Allnodes) is the de-facto default no-key RPC for many dapps and tends to lead the cohort because it terminates close to a large EU-anchored validator footprint." - - "{{name:drpc}} sits at {{p50:drpc}}. dRPC is a decentralized RPC mesh that routes each request across third-party node providers with consensus checks, so latency tracks the median of the underlying mesh rather than a single host." - - "{{name:flashbots}} clocks {{p50:flashbots}} on Ethereum. Flashbots Protect proxies reads through a standard Ethereum node behind the private-mempool write path, so read latency is a good proxy for how the proxy itself is provisioned, not for the upstream node." - - "Cloudflare-eth is a cautionary tale on this leaderboard. The endpoint returns HTTP 200 in well under a second, but a growing share of calls now resolve to `jsonrpc_err -32046 Cannot fulfill request` rather than a usable block, reliability is measured by `rpc_call_total{result=\"ok\"} / rpc_call_total` rather than HTTP status alone." - - "{{worst_name}} trails at {{worst_p50}} on the active tab. On a free public RPC the gap between leader and laggard is rarely about raw infrastructure speed; it is usually about how heavily a popular free endpoint is currently being hammered by the rest of the public, with no rate-limit budget to keep tail latency bounded." + - "Per-chain leaders, not a cross-chain crown. On Ethereum the current leader is {{best_name:chain:ethereum}} at {{best_p50:chain:ethereum}} (p50, 24 h) across 9 measured providers, the largest cohort in this bench. Ethereum is also the chain where Cloudflare-eth's `jsonrpc_err` traps the lazy reader." + - "On Base the leader is {{best_name:chain:base}} at {{best_p50:chain:base}} (p50, 24 h) across 6 providers. Base sees Coinbase's `mainnet.base.org` go head-to-head with PublicNode, dRPC, Tenderly and Merkle, an unusually clean comparison because the chain-official endpoint is operated by the same team that runs the sequencer." + - "On BNB Chain the leader is {{best_name:chain:bnb}} at {{best_p50:chain:bnb}} (p50, 24 h) across 5 providers. Binance's `bsc-dataseed1` is the incumbent, but PublicNode, dRPC and Merkle have closed the gap on EU origin nodes." + - "On Arbitrum the leader is {{best_name:chain:arbitrum}} at {{best_p50:chain:arbitrum}} (p50, 24 h) across 8 providers. Arbitrum is the second-largest cohort and one of the few chains where Lava and MeowRPC compete on a no-key tier alongside PublicNode and the Arbitrum Foundation's own endpoint." + - "{{name:publicnode}} returns {{p50:publicnode}} on the unfiltered aggregate. PublicNode (operated by Allnodes) is the de-facto default no-key RPC for many dapps because it terminates close to a large EU-anchored validator footprint and is one of the few providers present on most measured chains." + - "{{name:drpc}} sits at {{p50:drpc}} on the unfiltered aggregate. dRPC is a decentralized RPC mesh that routes each request across third-party node providers with consensus checks, so latency tracks the median of the underlying mesh rather than a single host." + - "Cloudflare-eth is a cautionary tale. The endpoint returns HTTP 200 in well under a second, but a growing share of calls now resolve to `jsonrpc_err -32046 Cannot fulfill request` rather than a usable block, reliability is measured by `rpc_call_total{result=\"ok\"} / rpc_call_total` rather than HTTP status alone." + - "Single-chain providers (Base official, Binance, Arbitrum Foundation, Optimism Foundation, Avalanche) mechanically inflate a cross-chain aggregate because they only emit metrics for their one chain. Read the leaderboard per chain and the comparison stays honest." faq: - q: "What is the fastest free public RPC right now?" - a: "{{best_name}} currently leads the active chain tab at {{best_p50}} (p50, 24 h) across {{count}} measured providers. The leaderboard re-sorts every 15 seconds against fresh Prometheus samples, so the answer reflects the last 24 hours of measured `eth_blockNumber` round-trip latency from eu-west, not a marketing-page claim. Switch the chain tab (10 EVM chains: Ethereum, Polygon, Arbitrum, Optimism, Base, BNB, Avalanche, Linea, Scroll, Mantle, or All) to see the leader on the network your product runs on." + a: "The question is per chain because several providers are single-chain by design (Base official, Binance, Arbitrum Foundation, Optimism Foundation, Avalanche) and a cross-chain aggregate mechanically rewards whichever single-chain endpoint happens to be fastest on its one chain. Current per-chain leaders. Ethereum {{best_name:chain:ethereum}} at {{best_p50:chain:ethereum}}, Base {{best_name:chain:base}} at {{best_p50:chain:base}}, BNB Chain {{best_name:chain:bnb}} at {{best_p50:chain:bnb}}, Arbitrum {{best_name:chain:arbitrum}} at {{best_p50:chain:arbitrum}}. The leaderboard re-sorts every 15 seconds against fresh Prometheus samples averaged across us-east, eu-west and sgp probes. Switch the chain tab to see the leader on the network your product runs on." - q: "Which public RPCs work without an API key in 2026?" a: "15 audited providers across 10 EVM chains. Universal multi-chain (work on 4+ chains): PublicNode, dRPC, 1RPC, Tenderly Gateway (`gateway.tenderly.co/public/`), Nodies (POKT successor at `*-pokt.nodies.app`). Ethereum-specific or limited footprint: MeowRPC (ETH + Arbitrum only since 2025), Flashbots Protect, Cloudflare (read-degraded), Lava Network (ETH + Arbitrum no-key, other chains require key), Merkle (Base + BSC stable, ETH locked-out by Cloudflare). Chain-official foundation RPCs: Arbitrum (`arb1.arbitrum.io/rpc`), Optimism (`mainnet.optimism.io`), Base (`mainnet.base.org`), Avalanche (`api.avax.network`), BNB (`bsc-dataseed1.binance.org`). Excluded: Ankr (key-gated), gateway.fm (29 req/IP), LlamaRPC / BlockPI / OmniaTech (Cloudflare 521 region-blocked), Alchemy demo (rate-limited dead), NodeReal / GetBlock / Chainstack (key-gated)." - q: "Why is Cloudflare-eth returning errors?" @@ -80,11 +88,11 @@ faq: - q: "Which free RPC supports Ethereum archive node calls?" a: "Most free public RPCs are state-pruned, so `eth_getBalance` at block (head, 5,000,000) returns an error rather than the historical balance. This benchmark probes five depths (300, 7,200, 216,000, 1,296,000, 5,000,000 blocks) every five minutes and exposes a gauge per provider per depth. The page surfaces a per-provider archive-depth badge: a green tick at the 5M tier means full historical state is available for free without a key. Anything pruned at 7200 confirms the endpoint is on a Geth default config and only serves the last ~24 hours." - q: "How is RPC latency measured on OpenChainBench?" - a: "The harness issues a single `eth_blockNumber` JSON-RPC POST every 15 seconds against each (provider, chain) pair from eu-west, using the same plain HTTP client for every endpoint. Wall-clock delta around the round-trip is recorded with millisecond precision and pushed to both a gauge and a histogram. p50/p90/p99 are computed via Prometheus `quantile_over_time` over the last 24 hours. Stale-head detection compares each response against the cross-provider tip and flags anything more than 20 blocks behind as `stale`, so a dead endpoint stuck on yesterday's block cannot rank as the fastest." + a: "The harness issues a single `eth_blockNumber` JSON-RPC POST every 15 seconds against each (provider, chain) pair from each of 3 regions (us-east, eu-west, sgp), using the same plain HTTP client for every endpoint. Wall-clock delta around the round-trip is recorded with millisecond precision and pushed to both a gauge and a histogram. p50/p90/p99 are computed via Prometheus `quantile_over_time` over the last 24 hours. Stale-head detection compares each response against the cross-provider tip and flags anything more than 20 blocks behind as `stale`, so a dead endpoint stuck on yesterday's block cannot rank as the fastest." - q: "Are public RPCs production-grade for a dapp?" a: "Read the success-rate column before deciding. A median latency under 100 ms is irrelevant if the endpoint silently returns a JSON-RPC error 5% of the time or stalls during an L1 reorg. Free public RPCs share rate-limit budgets with the entire internet and have no SLA. They are good for read-heavy demos, local development and fallback paths; production dapps usually graduate to a keyed tier (Alchemy, QuickNode, Infura) for `eth_sendRawTransaction`, websocket subscriptions and archive queries beyond Geth's pruned cap. This benchmark measures the no-key tier specifically because that is the unmeasured part of the market." -source: https://github.com/OpenChainBench/OpenChainBench/tree/main/harnesses/rpc-capabilities +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/rpc-capabilities prometheus: window: 24h @@ -105,6 +113,16 @@ dimensions: - { value: linea, label: Linea } - { value: scroll, label: Scroll } - { value: mantle, label: Mantle } + # Region values are the raw harness labels (REGION env on each Railway + # service: us-east / eu-west / sgp). Selecting one at the top of the + # page re-fetches all queries with `region="X"` injected — headline + # numbers + ranked-bar + chart all flip to that region. "All" leaves + # the queries unscoped, falling back to the cross-region average. + region: + - { value: all, label: All regions } + - { value: us-east, label: US-East } + - { value: eu-west, label: EU-West } + - { value: sgp, label: Singapore } # Real metrics emitted by the rpc-capabilities harness: # rpc_latency_milliseconds{provider, chain} gauge @@ -123,194 +141,344 @@ 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 eu-west to PublicNode's no-key endpoint." + 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 endpoint." queries: - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="publicnode"}[24h]) - p90: quantile_over_time(0.90, rpc_latency_milliseconds{provider="publicnode"}[24h]) - p99: quantile_over_time(0.99, rpc_latency_milliseconds{provider="publicnode"}[24h]) - mean: avg_over_time(rpc_latency_milliseconds{provider="publicnode"}[24h]) + p50: avg(quantile_over_time(0.50, rpc_latency_milliseconds{provider="publicnode"}[24h])) + p90: avg(quantile_over_time(0.90, rpc_latency_milliseconds{provider="publicnode"}[24h])) + p99: avg(quantile_over_time(0.99, rpc_latency_milliseconds{provider="publicnode"}[24h])) + mean: avg(avg_over_time(rpc_latency_milliseconds{provider="publicnode"}[24h])) success: sum(rate(rpc_call_total{provider="publicnode", result="ok"}[24h])) / sum(rate(rpc_call_total{provider="publicnode"}[24h])) sample_size: sum(increase(rpc_call_total{provider="publicnode"}[24h])) - series: avg_over_time(rpc_latency_milliseconds{provider="publicnode"}[1h]) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="publicnode"}[1h])) + regions: + - region: us-east + p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="publicnode", region="us-east"}[24h]) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", region="us-east"}[1h]) + - region: eu-west + p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="publicnode", region="eu-west"}[24h]) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", region="eu-west"}[1h]) + - region: ap-southeast + p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="publicnode", region="sgp"}[24h]) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode", 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 eu-west to dRPC's decentralized mesh." + 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 decentralized mesh." queries: - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="drpc"}[24h]) - p90: quantile_over_time(0.90, rpc_latency_milliseconds{provider="drpc"}[24h]) - p99: quantile_over_time(0.99, rpc_latency_milliseconds{provider="drpc"}[24h]) - mean: avg_over_time(rpc_latency_milliseconds{provider="drpc"}[24h]) + p50: avg(quantile_over_time(0.50, rpc_latency_milliseconds{provider="drpc"}[24h])) + p90: avg(quantile_over_time(0.90, rpc_latency_milliseconds{provider="drpc"}[24h])) + p99: avg(quantile_over_time(0.99, rpc_latency_milliseconds{provider="drpc"}[24h])) + mean: avg(avg_over_time(rpc_latency_milliseconds{provider="drpc"}[24h])) success: sum(rate(rpc_call_total{provider="drpc", result="ok"}[24h])) / sum(rate(rpc_call_total{provider="drpc"}[24h])) sample_size: sum(increase(rpc_call_total{provider="drpc"}[24h])) - series: avg_over_time(rpc_latency_milliseconds{provider="drpc"}[1h]) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="drpc"}[1h])) + regions: + - region: us-east + p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="drpc", region="us-east"}[24h]) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", region="us-east"}[1h]) + - region: eu-west + p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="drpc", region="eu-west"}[24h]) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", region="eu-west"}[1h]) + - region: ap-southeast + p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="drpc", region="sgp"}[24h]) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc", 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 eu-west to 1RPC's Automata gateway." + 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 Automata gateway." queries: - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="1rpc"}[24h]) - p90: quantile_over_time(0.90, rpc_latency_milliseconds{provider="1rpc"}[24h]) - p99: quantile_over_time(0.99, rpc_latency_milliseconds{provider="1rpc"}[24h]) - mean: avg_over_time(rpc_latency_milliseconds{provider="1rpc"}[24h]) + p50: avg(quantile_over_time(0.50, rpc_latency_milliseconds{provider="1rpc"}[24h])) + p90: avg(quantile_over_time(0.90, rpc_latency_milliseconds{provider="1rpc"}[24h])) + p99: avg(quantile_over_time(0.99, rpc_latency_milliseconds{provider="1rpc"}[24h])) + mean: avg(avg_over_time(rpc_latency_milliseconds{provider="1rpc"}[24h])) success: sum(rate(rpc_call_total{provider="1rpc", result="ok"}[24h])) / sum(rate(rpc_call_total{provider="1rpc"}[24h])) sample_size: sum(increase(rpc_call_total{provider="1rpc"}[24h])) - series: avg_over_time(rpc_latency_milliseconds{provider="1rpc"}[1h]) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="1rpc"}[1h])) + regions: + - region: us-east + p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="1rpc", region="us-east"}[24h]) + series: avg_over_time(rpc_latency_milliseconds{provider="1rpc", region="us-east"}[1h]) + - region: eu-west + p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="1rpc", region="eu-west"}[24h]) + series: avg_over_time(rpc_latency_milliseconds{provider="1rpc", region="eu-west"}[1h]) + - region: ap-southeast + p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="1rpc", region="sgp"}[24h]) + series: avg_over_time(rpc_latency_milliseconds{provider="1rpc", region="sgp"}[1h]) - slug: meowrpc name: MeowRPC tag: Free public RPC, no registration - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from eu-west to MeowRPC's no-key endpoint." + 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 MeowRPC's no-key endpoint." queries: - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="meowrpc"}[24h]) - p90: quantile_over_time(0.90, rpc_latency_milliseconds{provider="meowrpc"}[24h]) - p99: quantile_over_time(0.99, rpc_latency_milliseconds{provider="meowrpc"}[24h]) - mean: avg_over_time(rpc_latency_milliseconds{provider="meowrpc"}[24h]) + p50: avg(quantile_over_time(0.50, rpc_latency_milliseconds{provider="meowrpc"}[24h])) + p90: avg(quantile_over_time(0.90, rpc_latency_milliseconds{provider="meowrpc"}[24h])) + p99: avg(quantile_over_time(0.99, rpc_latency_milliseconds{provider="meowrpc"}[24h])) + mean: avg(avg_over_time(rpc_latency_milliseconds{provider="meowrpc"}[24h])) success: sum(rate(rpc_call_total{provider="meowrpc", result="ok"}[24h])) / sum(rate(rpc_call_total{provider="meowrpc"}[24h])) sample_size: sum(increase(rpc_call_total{provider="meowrpc"}[24h])) - series: avg_over_time(rpc_latency_milliseconds{provider="meowrpc"}[1h]) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="meowrpc"}[1h])) + regions: + - region: us-east + p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="meowrpc", region="us-east"}[24h]) + series: avg_over_time(rpc_latency_milliseconds{provider="meowrpc", region="us-east"}[1h]) + - region: eu-west + p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="meowrpc", region="eu-west"}[24h]) + series: avg_over_time(rpc_latency_milliseconds{provider="meowrpc", region="eu-west"}[1h]) + - region: ap-southeast + p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="meowrpc", region="sgp"}[24h]) + series: avg_over_time(rpc_latency_milliseconds{provider="meowrpc", region="sgp"}[1h]) - slug: flashbots - name: Flashbots Protect + name: Flashbots tag: Private-mempool RPC, anti-sandwich - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from eu-west to the Flashbots Protect read proxy." + 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 the Flashbots Protect read proxy." queries: - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="flashbots"}[24h]) - p90: quantile_over_time(0.90, rpc_latency_milliseconds{provider="flashbots"}[24h]) - p99: quantile_over_time(0.99, rpc_latency_milliseconds{provider="flashbots"}[24h]) - mean: avg_over_time(rpc_latency_milliseconds{provider="flashbots"}[24h]) + p50: avg(quantile_over_time(0.50, rpc_latency_milliseconds{provider="flashbots"}[24h])) + p90: avg(quantile_over_time(0.90, rpc_latency_milliseconds{provider="flashbots"}[24h])) + p99: avg(quantile_over_time(0.99, rpc_latency_milliseconds{provider="flashbots"}[24h])) + mean: avg(avg_over_time(rpc_latency_milliseconds{provider="flashbots"}[24h])) success: sum(rate(rpc_call_total{provider="flashbots", result="ok"}[24h])) / sum(rate(rpc_call_total{provider="flashbots"}[24h])) sample_size: sum(increase(rpc_call_total{provider="flashbots"}[24h])) - series: avg_over_time(rpc_latency_milliseconds{provider="flashbots"}[1h]) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="flashbots"}[1h])) + regions: + - region: us-east + p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="flashbots", region="us-east"}[24h]) + series: avg_over_time(rpc_latency_milliseconds{provider="flashbots", region="us-east"}[1h]) + - region: eu-west + p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="flashbots", region="eu-west"}[24h]) + series: avg_over_time(rpc_latency_milliseconds{provider="flashbots", region="eu-west"}[1h]) + - region: ap-southeast + p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="flashbots", region="sgp"}[24h]) + series: avg_over_time(rpc_latency_milliseconds{provider="flashbots", region="sgp"}[1h]) - slug: cloudflare name: Cloudflare tag: Permissioned-mode for many JSON-RPC methods - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from eu-west to cloudflare-eth (check success column for jsonrpc_err share)." + 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 cloudflare-eth (check success column for jsonrpc_err share)." queries: - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="cloudflare"}[24h]) - p90: quantile_over_time(0.90, rpc_latency_milliseconds{provider="cloudflare"}[24h]) - p99: quantile_over_time(0.99, rpc_latency_milliseconds{provider="cloudflare"}[24h]) - mean: avg_over_time(rpc_latency_milliseconds{provider="cloudflare"}[24h]) + p50: avg(quantile_over_time(0.50, rpc_latency_milliseconds{provider="cloudflare"}[24h])) + p90: avg(quantile_over_time(0.90, rpc_latency_milliseconds{provider="cloudflare"}[24h])) + p99: avg(quantile_over_time(0.99, rpc_latency_milliseconds{provider="cloudflare"}[24h])) + mean: avg(avg_over_time(rpc_latency_milliseconds{provider="cloudflare"}[24h])) success: sum(rate(rpc_call_total{provider="cloudflare", result="ok"}[24h])) / sum(rate(rpc_call_total{provider="cloudflare"}[24h])) sample_size: sum(increase(rpc_call_total{provider="cloudflare"}[24h])) - series: avg_over_time(rpc_latency_milliseconds{provider="cloudflare"}[1h]) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="cloudflare"}[1h])) + regions: + - region: us-east + p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="cloudflare", region="us-east"}[24h]) + series: avg_over_time(rpc_latency_milliseconds{provider="cloudflare", region="us-east"}[1h]) + - region: eu-west + p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="cloudflare", region="eu-west"}[24h]) + series: avg_over_time(rpc_latency_milliseconds{provider="cloudflare", region="eu-west"}[1h]) + - region: ap-southeast + p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="cloudflare", region="sgp"}[24h]) + series: avg_over_time(rpc_latency_milliseconds{provider="cloudflare", region="sgp"}[1h]) - slug: base-official - name: Base Official + name: Base tag: Coinbase-operated, Base mainnet RPC - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from eu-west to Coinbase's `mainnet.base.org` endpoint." + 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 Coinbase's `mainnet.base.org` endpoint." queries: - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="base-official"}[24h]) - p90: quantile_over_time(0.90, rpc_latency_milliseconds{provider="base-official"}[24h]) - p99: quantile_over_time(0.99, rpc_latency_milliseconds{provider="base-official"}[24h]) - mean: avg_over_time(rpc_latency_milliseconds{provider="base-official"}[24h]) + p50: avg(quantile_over_time(0.50, rpc_latency_milliseconds{provider="base-official"}[24h])) + p90: avg(quantile_over_time(0.90, rpc_latency_milliseconds{provider="base-official"}[24h])) + p99: avg(quantile_over_time(0.99, rpc_latency_milliseconds{provider="base-official"}[24h])) + mean: avg(avg_over_time(rpc_latency_milliseconds{provider="base-official"}[24h])) success: sum(rate(rpc_call_total{provider="base-official", result="ok"}[24h])) / sum(rate(rpc_call_total{provider="base-official"}[24h])) sample_size: sum(increase(rpc_call_total{provider="base-official"}[24h])) - series: avg_over_time(rpc_latency_milliseconds{provider="base-official"}[1h]) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="base-official"}[1h])) + regions: + - region: us-east + p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="base-official", region="us-east"}[24h]) + series: avg_over_time(rpc_latency_milliseconds{provider="base-official", region="us-east"}[1h]) + - region: eu-west + p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="base-official", region="eu-west"}[24h]) + series: avg_over_time(rpc_latency_milliseconds{provider="base-official", region="eu-west"}[1h]) + - region: ap-southeast + p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="base-official", region="sgp"}[24h]) + series: avg_over_time(rpc_latency_milliseconds{provider="base-official", region="sgp"}[1h]) - slug: binance - name: Binance Official + name: Binance tag: BNB Chain dataseed RPC, Binance-operated - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from eu-west to Binance's `bsc-dataseed1.binance.org` endpoint." + 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 Binance's `bsc-dataseed1.binance.org` endpoint." queries: - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="binance"}[24h]) - p90: quantile_over_time(0.90, rpc_latency_milliseconds{provider="binance"}[24h]) - p99: quantile_over_time(0.99, rpc_latency_milliseconds{provider="binance"}[24h]) - mean: avg_over_time(rpc_latency_milliseconds{provider="binance"}[24h]) + p50: avg(quantile_over_time(0.50, rpc_latency_milliseconds{provider="binance"}[24h])) + p90: avg(quantile_over_time(0.90, rpc_latency_milliseconds{provider="binance"}[24h])) + p99: avg(quantile_over_time(0.99, rpc_latency_milliseconds{provider="binance"}[24h])) + mean: avg(avg_over_time(rpc_latency_milliseconds{provider="binance"}[24h])) success: sum(rate(rpc_call_total{provider="binance", result="ok"}[24h])) / sum(rate(rpc_call_total{provider="binance"}[24h])) sample_size: sum(increase(rpc_call_total{provider="binance"}[24h])) - series: avg_over_time(rpc_latency_milliseconds{provider="binance"}[1h]) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="binance"}[1h])) + regions: + - region: us-east + p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="binance", region="us-east"}[24h]) + series: avg_over_time(rpc_latency_milliseconds{provider="binance", region="us-east"}[1h]) + - region: eu-west + p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="binance", region="eu-west"}[24h]) + series: avg_over_time(rpc_latency_milliseconds{provider="binance", region="eu-west"}[1h]) + - region: ap-southeast + p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="binance", region="sgp"}[24h]) + series: avg_over_time(rpc_latency_milliseconds{provider="binance", region="sgp"}[1h]) - slug: tenderly - name: Tenderly Gateway + name: Tenderly tag: Multi-chain public gateway, 9 chains, no key - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from eu-west to `gateway.tenderly.co/public/`." + 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 `gateway.tenderly.co/public/`." queries: - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="tenderly"}[24h]) - p90: quantile_over_time(0.90, rpc_latency_milliseconds{provider="tenderly"}[24h]) - p99: quantile_over_time(0.99, rpc_latency_milliseconds{provider="tenderly"}[24h]) - mean: avg_over_time(rpc_latency_milliseconds{provider="tenderly"}[24h]) + p50: avg(quantile_over_time(0.50, rpc_latency_milliseconds{provider="tenderly"}[24h])) + p90: avg(quantile_over_time(0.90, rpc_latency_milliseconds{provider="tenderly"}[24h])) + p99: avg(quantile_over_time(0.99, rpc_latency_milliseconds{provider="tenderly"}[24h])) + mean: avg(avg_over_time(rpc_latency_milliseconds{provider="tenderly"}[24h])) success: sum(rate(rpc_call_total{provider="tenderly", result="ok"}[24h])) / sum(rate(rpc_call_total{provider="tenderly"}[24h])) sample_size: sum(increase(rpc_call_total{provider="tenderly"}[24h])) - series: avg_over_time(rpc_latency_milliseconds{provider="tenderly"}[1h]) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="tenderly"}[1h])) + regions: + - region: us-east + p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="tenderly", region="us-east"}[24h]) + series: avg_over_time(rpc_latency_milliseconds{provider="tenderly", region="us-east"}[1h]) + - region: eu-west + p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="tenderly", region="eu-west"}[24h]) + series: avg_over_time(rpc_latency_milliseconds{provider="tenderly", region="eu-west"}[1h]) + - region: ap-southeast + p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="tenderly", region="sgp"}[24h]) + series: avg_over_time(rpc_latency_milliseconds{provider="tenderly", region="sgp"}[1h]) - slug: nodies - name: Nodies (POKT) + 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 eu-west to Nodies' `*-pokt.nodies.app` endpoint." + 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' `*-pokt.nodies.app` endpoint." queries: - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="nodies"}[24h]) - p90: quantile_over_time(0.90, rpc_latency_milliseconds{provider="nodies"}[24h]) - p99: quantile_over_time(0.99, rpc_latency_milliseconds{provider="nodies"}[24h]) - mean: avg_over_time(rpc_latency_milliseconds{provider="nodies"}[24h]) + p50: avg(quantile_over_time(0.50, rpc_latency_milliseconds{provider="nodies"}[24h])) + p90: avg(quantile_over_time(0.90, rpc_latency_milliseconds{provider="nodies"}[24h])) + p99: avg(quantile_over_time(0.99, rpc_latency_milliseconds{provider="nodies"}[24h])) + mean: avg(avg_over_time(rpc_latency_milliseconds{provider="nodies"}[24h])) success: sum(rate(rpc_call_total{provider="nodies", result="ok"}[24h])) / sum(rate(rpc_call_total{provider="nodies"}[24h])) sample_size: sum(increase(rpc_call_total{provider="nodies"}[24h])) - series: avg_over_time(rpc_latency_milliseconds{provider="nodies"}[1h]) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="nodies"}[1h])) + regions: + - region: us-east + p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="nodies", region="us-east"}[24h]) + series: avg_over_time(rpc_latency_milliseconds{provider="nodies", region="us-east"}[1h]) + - region: eu-west + p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="nodies", region="eu-west"}[24h]) + series: avg_over_time(rpc_latency_milliseconds{provider="nodies", region="eu-west"}[1h]) + - region: ap-southeast + p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="nodies", region="sgp"}[24h]) + series: avg_over_time(rpc_latency_milliseconds{provider="nodies", region="sgp"}[1h]) - slug: lava - name: Lava Network + name: Lava tag: Decentralized permissionless RPC mesh (ETH + Arbitrum no-key) - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from eu-west to Lava Network's no-key mesh endpoint." + 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 Network's no-key mesh endpoint." queries: - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="lava"}[24h]) - p90: quantile_over_time(0.90, rpc_latency_milliseconds{provider="lava"}[24h]) - p99: quantile_over_time(0.99, rpc_latency_milliseconds{provider="lava"}[24h]) - mean: avg_over_time(rpc_latency_milliseconds{provider="lava"}[24h]) + p50: avg(quantile_over_time(0.50, rpc_latency_milliseconds{provider="lava"}[24h])) + p90: avg(quantile_over_time(0.90, rpc_latency_milliseconds{provider="lava"}[24h])) + p99: avg(quantile_over_time(0.99, rpc_latency_milliseconds{provider="lava"}[24h])) + mean: avg(avg_over_time(rpc_latency_milliseconds{provider="lava"}[24h])) success: sum(rate(rpc_call_total{provider="lava", result="ok"}[24h])) / sum(rate(rpc_call_total{provider="lava"}[24h])) sample_size: sum(increase(rpc_call_total{provider="lava"}[24h])) - series: avg_over_time(rpc_latency_milliseconds{provider="lava"}[1h]) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="lava"}[1h])) + regions: + - region: us-east + p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="lava", region="us-east"}[24h]) + series: avg_over_time(rpc_latency_milliseconds{provider="lava", region="us-east"}[1h]) + - region: eu-west + p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="lava", region="eu-west"}[24h]) + series: avg_over_time(rpc_latency_milliseconds{provider="lava", region="eu-west"}[1h]) + - region: ap-southeast + p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="lava", region="sgp"}[24h]) + series: avg_over_time(rpc_latency_milliseconds{provider="lava", region="sgp"}[1h]) - slug: merkle name: Merkle tag: Base + BSC public no-key gateway (Ethereum hit by Cloudflare 20-min lockout, excluded) - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from eu-west to Merkle's Base/BSC no-key gateway." + 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 Merkle's Base/BSC no-key gateway." queries: - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="merkle"}[24h]) - p90: quantile_over_time(0.90, rpc_latency_milliseconds{provider="merkle"}[24h]) - p99: quantile_over_time(0.99, rpc_latency_milliseconds{provider="merkle"}[24h]) - mean: avg_over_time(rpc_latency_milliseconds{provider="merkle"}[24h]) + p50: avg(quantile_over_time(0.50, rpc_latency_milliseconds{provider="merkle"}[24h])) + p90: avg(quantile_over_time(0.90, rpc_latency_milliseconds{provider="merkle"}[24h])) + p99: avg(quantile_over_time(0.99, rpc_latency_milliseconds{provider="merkle"}[24h])) + mean: avg(avg_over_time(rpc_latency_milliseconds{provider="merkle"}[24h])) success: sum(rate(rpc_call_total{provider="merkle", result="ok"}[24h])) / sum(rate(rpc_call_total{provider="merkle"}[24h])) sample_size: sum(increase(rpc_call_total{provider="merkle"}[24h])) - series: avg_over_time(rpc_latency_milliseconds{provider="merkle"}[1h]) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="merkle"}[1h])) + regions: + - region: us-east + p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="merkle", region="us-east"}[24h]) + series: avg_over_time(rpc_latency_milliseconds{provider="merkle", region="us-east"}[1h]) + - region: eu-west + p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="merkle", region="eu-west"}[24h]) + series: avg_over_time(rpc_latency_milliseconds{provider="merkle", region="eu-west"}[1h]) + - region: ap-southeast + p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="merkle", region="sgp"}[24h]) + series: avg_over_time(rpc_latency_milliseconds{provider="merkle", region="sgp"}[1h]) - slug: arbitrum-official - name: Arbitrum Official + name: Arbitrum tag: Arbitrum Foundation public RPC, Arbitrum One only - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from eu-west to the Arbitrum Foundation's `arb1.arbitrum.io/rpc`." + 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 the Arbitrum Foundation's `arb1.arbitrum.io/rpc`." queries: - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="arbitrum-official"}[24h]) - p90: quantile_over_time(0.90, rpc_latency_milliseconds{provider="arbitrum-official"}[24h]) - p99: quantile_over_time(0.99, rpc_latency_milliseconds{provider="arbitrum-official"}[24h]) - mean: avg_over_time(rpc_latency_milliseconds{provider="arbitrum-official"}[24h]) + p50: avg(quantile_over_time(0.50, rpc_latency_milliseconds{provider="arbitrum-official"}[24h])) + p90: avg(quantile_over_time(0.90, rpc_latency_milliseconds{provider="arbitrum-official"}[24h])) + p99: avg(quantile_over_time(0.99, rpc_latency_milliseconds{provider="arbitrum-official"}[24h])) + mean: avg(avg_over_time(rpc_latency_milliseconds{provider="arbitrum-official"}[24h])) success: sum(rate(rpc_call_total{provider="arbitrum-official", result="ok"}[24h])) / sum(rate(rpc_call_total{provider="arbitrum-official"}[24h])) sample_size: sum(increase(rpc_call_total{provider="arbitrum-official"}[24h])) - series: avg_over_time(rpc_latency_milliseconds{provider="arbitrum-official"}[1h]) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="arbitrum-official"}[1h])) + regions: + - region: us-east + p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="arbitrum-official", region="us-east"}[24h]) + series: avg_over_time(rpc_latency_milliseconds{provider="arbitrum-official", region="us-east"}[1h]) + - region: eu-west + p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="arbitrum-official", region="eu-west"}[24h]) + series: avg_over_time(rpc_latency_milliseconds{provider="arbitrum-official", region="eu-west"}[1h]) + - region: ap-southeast + p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="arbitrum-official", region="sgp"}[24h]) + series: avg_over_time(rpc_latency_milliseconds{provider="arbitrum-official", region="sgp"}[1h]) - slug: optimism-official - name: Optimism Official + name: Optimism tag: Optimism Foundation public RPC, Optimism mainnet only - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from eu-west to the Optimism Foundation's `mainnet.optimism.io`." + 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 the Optimism Foundation's `mainnet.optimism.io`." queries: - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="optimism-official"}[24h]) - p90: quantile_over_time(0.90, rpc_latency_milliseconds{provider="optimism-official"}[24h]) - p99: quantile_over_time(0.99, rpc_latency_milliseconds{provider="optimism-official"}[24h]) - mean: avg_over_time(rpc_latency_milliseconds{provider="optimism-official"}[24h]) + p50: avg(quantile_over_time(0.50, rpc_latency_milliseconds{provider="optimism-official"}[24h])) + p90: avg(quantile_over_time(0.90, rpc_latency_milliseconds{provider="optimism-official"}[24h])) + p99: avg(quantile_over_time(0.99, rpc_latency_milliseconds{provider="optimism-official"}[24h])) + mean: avg(avg_over_time(rpc_latency_milliseconds{provider="optimism-official"}[24h])) success: sum(rate(rpc_call_total{provider="optimism-official", result="ok"}[24h])) / sum(rate(rpc_call_total{provider="optimism-official"}[24h])) sample_size: sum(increase(rpc_call_total{provider="optimism-official"}[24h])) - series: avg_over_time(rpc_latency_milliseconds{provider="optimism-official"}[1h]) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="optimism-official"}[1h])) + regions: + - region: us-east + p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="optimism-official", region="us-east"}[24h]) + series: avg_over_time(rpc_latency_milliseconds{provider="optimism-official", region="us-east"}[1h]) + - region: eu-west + p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="optimism-official", region="eu-west"}[24h]) + series: avg_over_time(rpc_latency_milliseconds{provider="optimism-official", region="eu-west"}[1h]) + - region: ap-southeast + p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="optimism-official", region="sgp"}[24h]) + series: avg_over_time(rpc_latency_milliseconds{provider="optimism-official", region="sgp"}[1h]) - slug: avalanche-official - name: Avalanche Official + name: Avalanche tag: Ava Labs C-Chain public RPC, Avalanche C-Chain only - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from eu-west to Ava Labs' `api.avax.network/ext/bc/C/rpc`." + 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 Ava Labs' `api.avax.network/ext/bc/C/rpc`." queries: - p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="avalanche-official"}[24h]) - p90: quantile_over_time(0.90, rpc_latency_milliseconds{provider="avalanche-official"}[24h]) - p99: quantile_over_time(0.99, rpc_latency_milliseconds{provider="avalanche-official"}[24h]) - mean: avg_over_time(rpc_latency_milliseconds{provider="avalanche-official"}[24h]) + p50: avg(quantile_over_time(0.50, rpc_latency_milliseconds{provider="avalanche-official"}[24h])) + p90: avg(quantile_over_time(0.90, rpc_latency_milliseconds{provider="avalanche-official"}[24h])) + p99: avg(quantile_over_time(0.99, rpc_latency_milliseconds{provider="avalanche-official"}[24h])) + mean: avg(avg_over_time(rpc_latency_milliseconds{provider="avalanche-official"}[24h])) success: sum(rate(rpc_call_total{provider="avalanche-official", result="ok"}[24h])) / sum(rate(rpc_call_total{provider="avalanche-official"}[24h])) sample_size: sum(increase(rpc_call_total{provider="avalanche-official"}[24h])) - series: avg_over_time(rpc_latency_milliseconds{provider="avalanche-official"}[1h]) + series: avg(avg_over_time(rpc_latency_milliseconds{provider="avalanche-official"}[1h])) + regions: + - region: us-east + p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="avalanche-official", region="us-east"}[24h]) + series: avg_over_time(rpc_latency_milliseconds{provider="avalanche-official", region="us-east"}[1h]) + - region: eu-west + p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="avalanche-official", region="eu-west"}[24h]) + series: avg_over_time(rpc_latency_milliseconds{provider="avalanche-official", region="eu-west"}[1h]) + - region: ap-southeast + p50: quantile_over_time(0.50, rpc_latency_milliseconds{provider="avalanche-official", region="sgp"}[24h]) + series: avg_over_time(rpc_latency_milliseconds{provider="avalanche-official", region="sgp"}[1h]) diff --git a/benchmarks/solana-dex-quote-latency.yml b/benchmarks/solana-dex-quote-latency.yml new file mode 100644 index 00000000..40bc7540 --- /dev/null +++ b/benchmarks/solana-dex-quote-latency.yml @@ -0,0 +1,186 @@ +# 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 ~80% of the time. 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." + - "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). Without an SOL ↔ USDC edge-cache shortcut to lean on, Jupiter's lite endpoint is doing the real routing search every tick. That's the steady-state number a long-running backend integration would actually see." + - "{{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}} returns quotes in {{p50:raydium}} (p50, 24 h). Raydium's Trade API is a single-venue compute endpoint (Raydium pools only). It cannot multi-hop via SOL, so it no-routes on long-tail tokens whose dominant liquidity lives elsewhere (Pump.fun, Meteora, Orca). Latency below is the conditional p50 on the subset of tokens it does cover." + - "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 + +# 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 } + +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 aggregated via Prometheus `histogram_quantile` over 24 hours." + - 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: "Median wallclock ms over 24h 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: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="jupiter"}[24h]))) + p90: histogram_quantile(0.90, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="jupiter"}[24h]))) + p99: histogram_quantile(0.99, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="jupiter"}[24h]))) + mean: sum(rate(solana_quote_latency_ms_sum{provider="jupiter"}[24h])) / sum(rate(solana_quote_latency_ms_count{provider="jupiter"}[24h])) + success: avg_over_time(solana_quote_success{provider="jupiter"}[24h]) + sample_size: sum(increase(solana_quote_latency_ms_count{provider="jupiter"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="jupiter"}[1h]))) + regions: + - region: us-east + p50: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="jupiter", region="us-east"}[24h]))) + series: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="jupiter", region="us-east"}[1h]))) + - region: eu-west + p50: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="jupiter", region="eu-west"}[24h]))) + series: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="jupiter", region="eu-west"}[1h]))) + - region: sgp + p50: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="jupiter", region="sgp"}[24h]))) + 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: "Median wallclock ms over 24h 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: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="mobula"}[24h]))) + p90: histogram_quantile(0.90, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="mobula"}[24h]))) + p99: histogram_quantile(0.99, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="mobula"}[24h]))) + mean: sum(rate(solana_quote_latency_ms_sum{provider="mobula"}[24h])) / sum(rate(solana_quote_latency_ms_count{provider="mobula"}[24h])) + success: avg_over_time(solana_quote_success{provider="mobula"}[24h]) + sample_size: sum(increase(solana_quote_latency_ms_count{provider="mobula"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="mobula"}[1h]))) + regions: + - region: us-east + p50: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="mobula", region="us-east"}[24h]))) + series: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="mobula", region="us-east"}[1h]))) + - region: eu-west + p50: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="mobula", region="eu-west"}[24h]))) + series: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="mobula", region="eu-west"}[1h]))) + - region: sgp + p50: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="mobula", region="sgp"}[24h]))) + 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: "Median wallclock ms over 24h 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: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="openocean"}[24h]))) + p90: histogram_quantile(0.90, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="openocean"}[24h]))) + p99: histogram_quantile(0.99, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="openocean"}[24h]))) + mean: sum(rate(solana_quote_latency_ms_sum{provider="openocean"}[24h])) / sum(rate(solana_quote_latency_ms_count{provider="openocean"}[24h])) + success: avg_over_time(solana_quote_success{provider="openocean"}[24h]) + sample_size: sum(increase(solana_quote_latency_ms_count{provider="openocean"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="openocean"}[1h]))) + regions: + - region: us-east + p50: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="openocean", region="us-east"}[24h]))) + series: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="openocean", region="us-east"}[1h]))) + - region: eu-west + p50: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="openocean", region="eu-west"}[24h]))) + series: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="openocean", region="eu-west"}[1h]))) + - region: sgp + p50: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="openocean", region="sgp"}[24h]))) + 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: "Median wallclock ms over 24h on Raydium's `transaction-v1.raydium.io/compute/swap-base-in` for 100 USDC → . Recorded only on `success: true`. Raydium is single-venue; ~30-50% no-route rate." + queries: + p50: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="raydium"}[24h]))) + p90: histogram_quantile(0.90, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="raydium"}[24h]))) + p99: histogram_quantile(0.99, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="raydium"}[24h]))) + mean: sum(rate(solana_quote_latency_ms_sum{provider="raydium"}[24h])) / sum(rate(solana_quote_latency_ms_count{provider="raydium"}[24h])) + success: avg_over_time(solana_quote_success{provider="raydium"}[24h]) + sample_size: sum(increase(solana_quote_latency_ms_count{provider="raydium"}[24h])) + series: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="raydium"}[1h]))) + regions: + - region: us-east + p50: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="raydium", region="us-east"}[24h]))) + series: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="raydium", region="us-east"}[1h]))) + - region: eu-west + p50: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="raydium", region="eu-west"}[24h]))) + series: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="raydium", region="eu-west"}[1h]))) + - region: sgp + p50: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="raydium", region="sgp"}[24h]))) + series: histogram_quantile(0.50, sum by (le) (rate(solana_quote_latency_ms_bucket{provider="raydium", region="sgp"}[1h]))) diff --git a/benchmarks/solana-tx-landing-latency.yml b/benchmarks/solana-tx-landing-latency.yml index 309c8112..9bb14231 100644 --- a/benchmarks/solana-tx-landing-latency.yml +++ b/benchmarks/solana-tx-landing-latency.yml @@ -3,8 +3,8 @@ slug: solana-tx-landing-latency number: "027" title: Solana transaction landing latency -seo_title: "Solana tx landing latency 2026: Jito vs Helius vs Nozomi vs Astralane time-to-land in slots" -seo_description: "Live active benchmark of Solana transaction landing services. Slot-level time-to-land (canonical on-chain measurement) plus wall-clock milliseconds. Identical signed mainnet probes submitted to each service from us-east every hour. Methodology pre-registered." +seo_title: "Solana tx landing latency 2026: Jito vs Helius vs Nozomi vs Astralane vs Mobula time-to-land in slots" +seo_description: "Live active benchmark of Solana transaction landing services. Slot-level time-to-land (canonical on-chain measurement) plus wall-clock milliseconds. Identical signed mainnet probes submitted to each service (Jito, Helius Sender, Astralane, Nozomi, Mobula) from us-east every hour. Methodology pre-registered." subtitle: How fast does each landing service get a signed mainnet tx confirmed. Slot delta = number of Solana slots between submit and confirmed (1 slot ≈ 400 ms). Active probing, identical signed txs every hour from us-east. category: Trading status: live @@ -93,10 +93,10 @@ methodology: - "Drop classification. timeout = no confirmation within 60 s. invalid = RPC error, on-chain Err, or BlockhashNotFound. network_error = transport-level (timeout, DNS, EOF, connection refused). rate_limited = HTTP 419 / 429 / 'rate limit' / 'too many requests'. landing_rate is published as success / (success + timeout), rate_limited and network_error are excluded so quota / transport issues don't bias the bench against a throttled service." - "Jito control probe. Helius (default), Astralane, Nozomi route a portion of flow through Jito internally, conflating own-path vs Jito-caught-it. Jito is in the V0-Lean set so its control fires in the same cycle. Same slot_delta as Jito = no measurable own-path value. Suspect ahead by 1+ slot = real routing advantage." - "Reproducibility. The full harness source is at github.com/MobulaFi/mobula-monorepo/tree/main/miniapps/solana-tx-landing. Anyone with a funded Solana keypair (~1 SOL) can clone, set SOLANA_PROBE_KEYPAIR_BASE58, run the binary, and reproduce these metrics. The bench does not rely on any private or internal service for measurement, the only RPC dependency is the public `api.mainnet-beta.solana.com` HTTP + WebSocket endpoints." - - "Methodology v1.3 pre-registered at github.com/OpenChainBench/OpenChainBench/blob/main/docs/methodology/solana-tx-landing-active.md. Any change (tip floor, probe payload, cadence, region, metric definitions) ships as a public PR with a 14-day comment window. Major version bumps run a 30-day shadow period publishing old and new metrics in parallel." + - "Methodology v1.3 pre-registered at github.com/ChainBench/OpenChainBench/blob/main/docs/methodology/solana-tx-landing-active.md. Any change (tip floor, probe payload, cadence, region, metric definitions) ships as a public PR with a 14-day comment window. Major version bumps run a 30-day shadow period publishing old and new metrics in parallel." findings: - - "{{best_name}} leads the V0-Lean probe set at p50 = {{best_p50}} slot delta over the rolling 7-day window. Lower = fewer Solana slots between submission and confirmation. The gap between fastest and slowest is the operational signal, every service claims '99 %+ landing rate' in marketing copy, but the chain doesn't lie about which slot included your tx. A 1-slot difference is ~400 ms — enough for a MEV bot to front-run a competitor." + - "{{best_name}} leads the V0-Lean probe set at p50 = {{best_p50}} slot delta over the rolling 7-day window. Lower = fewer Solana slots between submission and confirmation. The gap between fastest and slowest is the operational signal, every service claims '99 %+ landing rate' in marketing copy, but the chain doesn't lie about which slot included your tx. A 1-slot difference is ~400 ms, enough for a MEV bot to front-run a competitor." - "{{name:jito}} is the baseline / control. Helius (default mode), Astralane, and Nozomi all internally fan out to Jito, so the Jito p50 is the floor any premium service must beat. Same slot_delta as Jito on a given cycle = the service is essentially using Jito as its inclusion path. {{name:jito}} sits at p50 = {{p50:jito}} slot delta." - "{{name:helius-sender}} in `swqos_only` mode isolates Helius's own routing path from the Jito leg. p50 = {{p50:helius-sender}} slot delta. A v1.0.1 methodology update will publish Helius default mode (with Jito fan-out) side-by-side for direct comparison." - "{{name:nozomi}} premium pricing (1M lamport hard floor, ~10 × Jito's competitive level) only makes economic sense if the slot_delta advantage is meaningful. p50 = {{p50:nozomi}} slot delta. The gap vs Jito quantifies whether the tip premium buys real slot priority." @@ -124,7 +124,7 @@ faq: - q: "How is the confirmation observed?" a: "Via `signatureSubscribe` on the public mainnet WebSocket (`wss://api.mainnet-beta.solana.com`). The subscription is registered BEFORE submission so a fast-confirming tx cannot complete before we are listening (otherwise we'd miss the notification and timeout spuriously). The RPC pushes the notification at the instant the commitment level is reached, so observation resolution is RTT-bounded (~30-50 ms us-east → mainnet-beta) and slot_delta is read directly from the notification's context.slot field. HTTP polling at 200 ms is an automatic fallback if the WebSocket fails to connect for a given cycle." -source: https://github.com/OpenChainBench/OpenChainBench/tree/main/harnesses/solana-tx-landing +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/solana-tx-landing prometheus: window: 7d @@ -156,7 +156,7 @@ prometheus: providers: - slug: jito - name: Jito Block Engine + name: Jito tag: Baseline + control probe; atomic bundles + tip auction since 2022 formula: "50th percentile over 7d of slot delta (land_slot − submit_slot) for hourly signed mainnet probes submitted to Jito's `ny.mainnet.block-engine.jito.wtf` from us-east." queries: @@ -169,7 +169,7 @@ providers: series: solana_landing_probe_latency_slots{service="jito",region="us-east"} - slug: helius-sender - name: Helius Sender (swqos_only) + name: Helius tag: Isolated Helius own-path (no Jito fan-out); anycast + 7 POPs formula: "50th percentile over 7d of slot delta (land_slot − submit_slot) for hourly signed probes submitted to Helius Sender in `swqos_only=true` mode from us-east, isolating its own-path." queries: @@ -182,7 +182,7 @@ providers: series: solana_landing_probe_latency_slots{service="helius-sender",mode="swqos_only",region="us-east"} - slug: astralane - name: Astralane Iris + name: Astralane tag: Tip-refund mechanism, sendBundle / sendIdeal modes, FRA + NY POPs formula: "50th percentile over 7d of slot delta (land_slot − submit_slot) for hourly signed probes submitted with a 500k-lamport net tip to Astralane Iris's NY gateway from us-east." queries: @@ -206,3 +206,16 @@ providers: success: sum(rate(solana_landing_probe_success_total{service="nozomi",region="us-east"}[7d])) / (sum(rate(solana_landing_probe_success_total{service="nozomi",region="us-east"}[7d])) + sum(rate(solana_landing_probe_dropped_total{service="nozomi",region="us-east",reason="timeout"}[7d]))) sample_size: sum(increase(solana_landing_probe_success_total{service="nozomi",region="us-east"}[7d])) series: solana_landing_probe_latency_slots{service="nozomi",region="us-east"} + + - slug: mobula + name: Mobula + tag: Multi-RPC fan-out aggregator (relays via Jito / Nozomi / zeroslot) + formula: "50th percentile over 7d of slot delta (land_slot − submit_slot) for hourly probes submitted via Mobula's `api.mobula.io/api/2/swap/send` multi-RPC fan-out from us-east, using a Jito tip wallet." + queries: + p50: quantile_over_time(0.5, solana_landing_probe_latency_slots{service="mobula",region="us-east"}[7d]) + p90: quantile_over_time(0.9, solana_landing_probe_latency_slots{service="mobula",region="us-east"}[7d]) + p99: quantile_over_time(0.99, solana_landing_probe_latency_slots{service="mobula",region="us-east"}[7d]) + mean: avg_over_time(solana_landing_probe_latency_slots{service="mobula",region="us-east"}[7d]) + success: sum(rate(solana_landing_probe_success_total{service="mobula",region="us-east"}[7d])) / (sum(rate(solana_landing_probe_success_total{service="mobula",region="us-east"}[7d])) + sum(rate(solana_landing_probe_dropped_total{service="mobula",region="us-east",reason="timeout"}[7d]))) + sample_size: sum(increase(solana_landing_probe_success_total{service="mobula",region="us-east"}[7d])) + series: solana_landing_probe_latency_slots{service="mobula",region="us-east"} diff --git a/benchmarks/solana-tx-landing.yml b/benchmarks/solana-tx-landing.yml index 19fbbac0..567e2dd7 100644 --- a/benchmarks/solana-tx-landing.yml +++ b/benchmarks/solana-tx-landing.yml @@ -92,7 +92,7 @@ faq: - 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/OpenChainBench/OpenChainBench/tree/main/harnesses/solana-tx-landing +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/solana-tx-landing prometheus: window: 24h @@ -125,7 +125,7 @@ providers: series: sum(rate(solana_landing_tx_total{service="jito"}[5m])) - slug: helius-sender - name: 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: @@ -151,7 +151,7 @@ providers: series: sum(rate(solana_landing_tx_total{service="nozomi"}[5m])) - slug: bloxroute - name: bloXroute Trader API + 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: @@ -164,7 +164,7 @@ providers: series: sum(rate(solana_landing_tx_total{service="bloxroute"}[5m])) - slug: 0slot - name: 0slot.trade + 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: @@ -190,7 +190,7 @@ providers: series: sum(rate(solana_landing_tx_total{service="nextblock"}[5m])) - slug: astralane - name: Astralane Iris + 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: diff --git a/benchmarks/stablecoin-peg-usdt-anchored.yml b/benchmarks/stablecoin-peg-usdt-anchored.yml index 6ebb421a..bf74d2c1 100644 --- a/benchmarks/stablecoin-peg-usdt-anchored.yml +++ b/benchmarks/stablecoin-peg-usdt-anchored.yml @@ -88,7 +88,7 @@ faq: - q: "Which stable should I hold on Binance for the lowest rotation cost?" a: "{{best_name}} currently has the tightest pair at {{best_p50}} (p99 spread vs USDT, 24 h). The leaderboard re-sorts every minute. For inventory sizing, use p99 rather than p50, because the spread during a forced rebalance under stress is what determines actual cost, not the calm-minute median. Combine with the success-rate column and the fundamental reserve-risk story (which this bench does NOT capture) before committing meaningful inventory." -source: https://github.com/OpenChainBench/OpenChainBench/tree/main/harnesses/stablecoin-peg +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/stablecoin-peg prometheus: window: 24h diff --git a/benchmarks/stablecoin-peg.yml b/benchmarks/stablecoin-peg.yml index 6f4bd736..e92ed949 100644 --- a/benchmarks/stablecoin-peg.yml +++ b/benchmarks/stablecoin-peg.yml @@ -86,7 +86,7 @@ faq: - q: "Why ignore CoinGecko or CoinMarketCap prices?" a: "Aggregator prices (CoinGecko, CoinMarketCap, DefiLlama) are themselves liquidity-weighted medians of the venues this benchmark already polls directly, refreshed every 1-5 minutes. Re-aggregating an aggregator adds latency, hides the venue-level cross-spread that actually matters during stress (USDC briefly $0.9978 on Kraken while $1.0003 on Coinbase), and inherits whatever venue selection the aggregator chose. The benchmark goes to the source venues directly so the cross-venue gap is visible and the depeg event flag fires within 5 minutes of the price actually breaking the band." -source: https://github.com/OpenChainBench/OpenChainBench/tree/main/harnesses/stablecoin-peg +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/stablecoin-peg prometheus: window: 24h diff --git a/benchmarks/validator-yield.yml b/benchmarks/validator-yield.yml index 4cb09cd5..4099e4e6 100644 --- a/benchmarks/validator-yield.yml +++ b/benchmarks/validator-yield.yml @@ -100,7 +100,7 @@ faq: - q: "Why cap Solana at the top 200 validators?" a: "Prometheus cardinality budget. The full Solana validator set is ~3000; tracking all of them across `net_yield`, `gross_yield`, `mev_share`, `commission`, `uptime`, `stake_usd`, `jailed` would land ~21k label combinations on the OCB Prom, vs ~1.6k for the top 200. The top 200 by activated stake represent ~90% of staked SOL by USD value, so the chain median is representative of where real delegated stake sits, not skewed by the long tail of <1k-SOL validators that have negligible delegate base. The cap is a knob, if Prom capacity grows, it lifts to 500 or 1000." -source: https://github.com/OpenChainBench/OpenChainBench/tree/main/harnesses/validator-yield +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/validator-yield prometheus: window: 24h diff --git a/benchmarks/wallet-labels-coverage.yml b/benchmarks/wallet-labels-coverage.yml index 930fcfd5..d6d95d3d 100644 --- a/benchmarks/wallet-labels-coverage.yml +++ b/benchmarks/wallet-labels-coverage.yml @@ -67,7 +67,7 @@ faq: - q: "What does 'wallet labeling' mean in a crypto API?" a: "A wallet labeling API takes an address and returns an entity name. The CEX it belongs to (`Binance hot wallet 14`), the protocol (`Uniswap V3 router`), the multisig owner (`Safe: foundation treasury`), the sanctioned status (`OFAC SDN`), or a public-figure tag (`Vitalik Buterin`). Generic categorical labels like `EOA`, `Contract`, `Wallet` are not considered a hit on this benchmark because they carry no entity signal." - q: "Is Mobula's labels API better than Moralis or Helius?" - a: "It depends on the chain. {{name:mobula}} is a universal provider audited on every chain it advertises and currently returns {{p50:mobula}} on the active tab. {{name:helius}} is Solana-only and dominates that chain because its label graph is curated against native Solana programs. {{name:moralis}} is EVM-centric and trails on TON, Stellar, XRP and Bitcoin. The per-chain tabs are the honest comparison; the All chains tab is the universal-coverage story." + a: "It depends on the chain. {{name:mobula}} is a universal provider audited on every chain it advertises and currently returns {{p50:mobula}} on the active tab. {{name:helius}} is Solana-only and dominates that chain because its label graph is curated against native Solana programs. {{name:moralis}} is EVM-centric and trails on TON, Stellar, XRP and Bitcoin. The per-chain tabs are the honest comparison." - q: "What is the alternative to Arkham or Nansen for builders?" a: "Arkham and Nansen own the consumer visualization layer (browse-the-web-of-onchain-money). The builder side of the question, the API integrated under the hood by wallets, portfolio trackers and AML flows, lives elsewhere. Mobula, Helius, Moralis, Blockscout, OLI, TonAPI, StellarExpert, XRPScan and WalletExplorer are the labeling APIs benchmarked here. Pick the one whose coverage matches the chains your product touches and whose response shape fits your integration latency budget." - q: "How does OpenChainBench measure wallet label coverage?" @@ -75,7 +75,7 @@ faq: - q: "Which wallet labeling API has the best Solana coverage?" a: "{{name:helius}} returns {{p50:helius}} on Solana. Chain-specialists dominate their home chain because their label graph is curated against native protocols (Jito stake pools, Jupiter routers, Pump.fun creators, Magic Eden marketplace programs) rather than translated from an EVM-shaped schema. Universal providers (Mobula, Moralis) are also audited on Solana, but switch the chain tab to Solana to see the direct comparison rather than the cross-chain average." -source: https://github.com/OpenChainBench/OpenChainBench/tree/main/harnesses/wallet-labels +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/wallet-labels prometheus: window: 24h @@ -86,7 +86,6 @@ prometheus: # value `all` skips the filter, aggregate over every chain at once. dimensions: chain: - - { value: all, label: All chains } - { value: ethereum, label: Ethereum } - { value: solana, label: Solana } - { value: bnb, label: BNB Chain } diff --git a/docs/methodology/relay-link-revenue.md b/docs/methodology/bridge-revenue.md similarity index 92% rename from docs/methodology/relay-link-revenue.md rename to docs/methodology/bridge-revenue.md index e7f0834f..4232f934 100644 --- a/docs/methodology/relay-link-revenue.md +++ b/docs/methodology/bridge-revenue.md @@ -1,8 +1,8 @@ -# Methodology - Relay.link Implied Protocol Revenue +# Methodology - Cross-chain bridge implied protocol revenue (Relay sub-methodology) > **Pre-registered methodology.** Pinned before any external party is invited to use the bench number for due diligence, deal terms, or commentary. Changes ship as public PRs with a 14-day comment window. Disputes go through public GitHub issues. > -> **Version :** v1.0 - first commit 2026-05-23. Bench № 028 (Relay.link implied protocol revenue). +> **Version :** v1.0 - first commit 2026-05-23. Bench № 028 (cross-chain bridge implied protocol revenue). This file documents the Relay.link sub-methodology; additional bridges (Across, Socket, deBridge) land as sibling sections once their harnesses ship. --- @@ -122,7 +122,7 @@ This mapping is explicit on the bench page FAQ so readers do not misread the col ## 11. Reproducibility -The full harness source is published at `harnesses/relay-link-revenue/` in the OpenChainBench monorepo (Go). Anyone can clone, set the Relay.link API endpoint env var, configure a USD price source, run the binary against a Prometheus scraper, and reproduce all metrics. The bench does not rely on any internal Mobula service for measurement. +The Relay sub-harness currently lives in the private `mobula-api/miniapps/relay-link-revenue/` tree (Go) — the bench spec self-links to its YAML for now and the harness is on the backlog to port to the public OCB repo. Anyone with the Relay.link public API endpoint and a USD price source can reproduce the metrics by running their own implementation against the published methodology. The bench does not rely on any internal Mobula service for measurement. Anyone replicating the bench from a different USD-price source will see different absolute numbers (CoinGecko vs. Pyth vs. on-chain DEX price will disagree on long-tail tokens) but should see the same trend over a rolling 7-day window. Discrepancies of more than ~10 % in the 7d revenue sum between independent replications should be filed as GitHub issues with the price-source diff attached. diff --git a/docs/walkthrough.md b/docs/walkthrough.md index b69e42fe..d054f58b 100644 --- a/docs/walkthrough.md +++ b/docs/walkthrough.md @@ -12,7 +12,7 @@ The whole journey takes them roughly **3-4 hours of focused work**, spread over ## Phase 1. Align (Day 0, ~30 min) -Alex opens an issue with the [📊 Propose a benchmark](https://github.com/OpenChainBench/OpenChainBench/issues/new?template=new-benchmark.yml) template. They fill in: +Alex opens an issue with the [📊 Propose a benchmark](https://github.com/ChainBench/OpenChainBench/issues/new?template=new-benchmark.yml) template. They fill in: - **What to measure**: time-to-receive-full-portfolio in milliseconds. - **Providers**: Mobula, Zerion, DeBank, Quick Intel. @@ -56,7 +56,7 @@ methodology: - "Timeout: 5,000 ms. Failures excluded from latency aggregates." - "Regions: us-east-1, eu-west-1, ap-southeast-1." -source: https://github.com/OpenChainBench/OpenChainBench/tree/main/harnesses/wallet-portfolio-latency +source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/wallet-portfolio-latency prometheus: url: https://prom.openchainbench.com @@ -254,7 +254,7 @@ Alex's benchmark is live. The roadmap card moves to `Live`. The maintainer pings | Alex moves the harness to a new URL | Alex (or the maintainer, if Alex disappears) opens a 1-line PR updating the scrape config. Prom reloads, scraping resumes. | | One provider in the bench rate-limits Alex | That provider's success rate drops on the page; latency aggregates exclude failures so the bench stays interpretable. | | Alex stops paying for the harness | The bench goes dark. After ~30 days of no data, a maintainer can archive the bench (move issue to `Archived`, mark spec `status: draft` in the YAML). The code stays in the repo for anyone to revive. | -| A provider files a [provider correction](https://github.com/OpenChainBench/OpenChainBench/issues/new?template=provider-correction.yml) saying "your number is wrong" | Public discussion in the issue. If they're right and provide a reproducer, the YAML or harness gets a correction with a dated note on the report. | +| A provider files a [provider correction](https://github.com/ChainBench/OpenChainBench/issues/new?template=provider-correction.yml) saying "your number is wrong" | Public discussion in the issue. If they're right and provide a reproducer, the YAML or harness gets a correction with a dated note on the report. | ## What this enables diff --git a/harnesses/README.md b/harnesses/README.md index b6a1bff5..04ff9c13 100644 --- a/harnesses/README.md +++ b/harnesses/README.md @@ -54,7 +54,7 @@ harnesses// See [`/CONTRIBUTING.md`](../CONTRIBUTING.md) for the full submission flow. Short version: -1. Open an issue with the [📊 Propose a benchmark template](https://github.com/OpenChainBench/OpenChainBench/issues/new?template=new-benchmark.yml). +1. Open an issue with the [📊 Propose a benchmark template](https://github.com/ChainBench/OpenChainBench/issues/new?template=new-benchmark.yml). 2. Build the harness here at `harnesses//`. 3. Deploy it on whatever infra you prefer, expose `/metrics` over HTTPS at a stable URL. 4. Append a scrape job to [`infrastructure/prometheus/prometheus.yml`](../infrastructure/prometheus/prometheus.yml) pointing at your URL. diff --git a/harnesses/aggregator-head-lag/.gitignore b/harnesses/aggregator-head-lag/.gitignore index ec3fb764..8ef33851 100644 --- a/harnesses/aggregator-head-lag/.gitignore +++ b/harnesses/aggregator-head-lag/.gitignore @@ -32,3 +32,6 @@ Thumbs.db # Temporary files tmp/ temp/ + +# Local build output from `go build ./cmd/script` +/script diff --git a/harnesses/aggregator-head-lag/cmd/script/log_buffer.go b/harnesses/aggregator-head-lag/cmd/script/log_buffer.go index 0f7455e0..a0cce9a2 100644 --- a/harnesses/aggregator-head-lag/cmd/script/log_buffer.go +++ b/harnesses/aggregator-head-lag/cmd/script/log_buffer.go @@ -79,12 +79,16 @@ func installLogCapture() { } // setupLogsEndpoint exposes GET /logs?tail=N (default 500, max logBufferMax). -// If LOGS_TOKEN env var is set, requires header `X-Logs-Token` to match. -// Otherwise the endpoint is open — only safe for Railway-internal access. +// Fail-secure: when LOGS_TOKEN env var is not set, the endpoint returns 404 +// (refuses by default). When set, requires header `X-Logs-Token` to match. func setupLogsEndpoint(mux *http.ServeMux) { expectedToken := os.Getenv("LOGS_TOKEN") mux.HandleFunc("/logs", func(w http.ResponseWriter, r *http.Request) { - if expectedToken != "" && r.Header.Get("X-Logs-Token") != expectedToken { + if expectedToken == "" { + http.NotFound(w, r) + return + } + if r.Header.Get("X-Logs-Token") != expectedToken { http.Error(w, "forbidden", http.StatusForbidden) return } diff --git a/harnesses/aggregator-head-lag/cmd/script/main.go b/harnesses/aggregator-head-lag/cmd/script/main.go index c0a7eaa7..84444379 100644 --- a/harnesses/aggregator-head-lag/cmd/script/main.go +++ b/harnesses/aggregator-head-lag/cmd/script/main.go @@ -47,50 +47,17 @@ func main() { } }() - // Mobula Pulse V2 feeder — only feeds the metadata coverage queue, - // no pulse-specific metrics emitted (see mobula_pulse_monitor.go). - wg.Add(1) - go func() { - defer wg.Done() - runMobulaPulseMonitor(config, stopChan) - }() - - // Mobula REST API monitor - wg.Add(1) - go func() { - defer wg.Done() - runMobulaRESTMonitor(config, stopChan) - }() - - // Codex REST API monitor - wg.Add(1) - go func() { - defer wg.Done() - runCodexRESTMonitor(config, stopChan) - }() - - // Quote API latency monitor (Jupiter, Li.Fi, 1inch, KyberSwap) - wg.Add(1) - go func() { - defer wg.Done() - runQuoteAPIMonitor(config, stopChan) - }() - - // Metadata coverage monitor (Mobula vs Codex) - wg.Add(1) - go func() { - defer wg.Done() - runMetadataCoverageMonitor(config, stopChan) - }() - - // Head lag monitor (blockchain head vs aggregator indexed head) + // Head lag monitor — the only data producer for this binary. + // Measures the gap between an on-chain Swap event and its appearance + // on each aggregator's WebSocket / REST feed. wg.Add(1) go func() { defer wg.Done() runHeadLagMonitor(config, stopChan) }() - // Mobula Fast-Trade monitor (for comparison with Pulse V2) + // Mobula Fast-Trade monitor — used for the detailed Mobula breakdown + // (processing vs network lag) emitted alongside head lag. wg.Add(1) go func() { defer wg.Done() diff --git a/harnesses/hyperliquid-frontends/Dockerfile b/harnesses/hyperliquid-frontends/Dockerfile new file mode 100644 index 00000000..89acec73 --- /dev/null +++ b/harnesses/hyperliquid-frontends/Dockerfile @@ -0,0 +1,13 @@ +FROM golang:1.23 AS build +WORKDIR /src +COPY go.mod go.sum* ./ +RUN go mod download || true +COPY . . +RUN CGO_ENABLED=0 go build -o /out/script ./cmd/script + +FROM gcr.io/distroless/static-debian12:nonroot +WORKDIR /app +COPY --from=build /out/script /app/script +COPY builders.json /app/builders.json +EXPOSE 2112 +ENTRYPOINT ["/app/script"] diff --git a/harnesses/hyperliquid-frontends/README.md b/harnesses/hyperliquid-frontends/README.md new file mode 100644 index 00000000..11e994e7 --- /dev/null +++ b/harnesses/hyperliquid-frontends/README.md @@ -0,0 +1,78 @@ +# hyperliquid-frontends harness + +OpenChainBench bench № 030 — quality benchmark of Hyperliquid frontends, ranked by **how much they extract from users**, not by raw volume share. + +## What it measures + +Three quality metrics per builder, refreshed hourly from the public daily fills dumps Hyperliquid publishes at `https://stats-data.hyperliquid.xyz/Mainnet/builder_fills/{address}/{YYYYMMDD}.csv.lz4`: + +| Metric | Definition | +|---|---| +| **Effective fee bps** | `sum(builder_fee) / sum(notional) × 10 000` — volume-weighted average over 24 h | +| **$ per user** | `sum(builder_fee) / count(distinct user)` — raw efficiency per active trader | +| **Fee discipline** | `stddev_over_time(effective_fee_bps[30d])` — computed in Prometheus, surfaces rotating-promo cycles | + +Headline ranking = lowest effective fee = most aligned with traders. + +Why this framing: the Hyperliquid frontend wars already have a dozen volume-share dashboards (ASXN HyperScreener, Coinmarketman HyperTracker, Flowscan, Allium, Hyperdash, several Dune boards). What nobody publishes cleanly is the user-cost side. This bench is the user-cost side. + +## Builder registry + +`builders.json` is a hand-curated `[{slug, name, address, valid_from, notes}]` array. Addresses are cross-referenced against: +- Flowscan `/builders` — live leaderboard +- Hyperliquid governance forum disclosures +- Each frontend's public announcement of their builder code + +**The committed file ships with placeholder `0x0000…` addresses.** They MUST be filled in before the harness can fetch anything useful. The harness will return `403` for every placeholder address (the Hyperliquid bucket returns 403 when the date file doesn't exist for that address). + +Workflow for adding / updating addresses: +1. Pull the latest Flowscan builders list, cross-check with ASXN's published mapping +2. Edit `builders.json`, bump `valid_from` to today +3. PR with a Flowscan screenshot in the description so the registry change is auditable +4. After merge, redeploy the harness on Railway + +## Prometheus metrics emitted + +``` +hl_frontend_effective_fee_bps{builder} gauge +hl_frontend_fees_per_user_usd{builder} gauge +hl_frontend_volume_usd_24h{builder} gauge +hl_frontend_users_24h{builder} gauge +hl_frontend_fills_total{builder} gauge +hl_frontend_unattributed_share_pct gauge +hl_frontend_registry_age_seconds gauge +hl_frontend_csv_fetch_status_total{builder,code} counter +``` + +Bench page column mapping is documented in `benchmarks/hyperliquid-frontends.yml` — p50 / p90 / p99 are repurposed for effective fee / 30d stddev / $/user respectively since the unit is bps and there is no native percentile semantics. + +## Run locally + +```bash +cd harnesses/hyperliquid-frontends +go run ./cmd/script +curl http://localhost:2112/metrics | grep hl_frontend_ +``` + +## Deploy + +Standard OCB-miniapp shape — multi-stage Dockerfile, port 2112, scraped by the shared `openchainbench-monitoring` Prometheus via `hyperliquid-frontends.railway.internal:2112`. + +After deploy, add a scrape config entry: + +```yaml +- job_name: 'hyperliquid-frontends' + static_configs: + - targets: + - 'hyperliquid-frontends.railway.internal:2112' + labels: + benchmark: hyperliquid-frontends + metrics_path: /metrics +``` + +## Known limits + +- **Daily granularity** — the CSV bucket only rolls at UTC midnight. Intra-day movement is invisible. A v1.1 upgrade would consume the WebSocket `userFills` stream filtered on the `b` field for realtime. +- **Native HL UI excluded** — orders without a builder code are not attributable here. Covered by the separate `/benchmarks/aggregator-head-lag` bench. +- **Wash trading** — fills are taken at face value. A frontend running wash flow shows up exactly as the chain records. Mitigation = the unique-user column flags anomalous low-user / high-volume signatures. A sybil-cluster heuristic ships in v1.1. +- **Registry maintenance** — the builder address list is hand-curated. The `hl_frontend_unattributed_share_pct` metric makes the coverage gap visible; an alert fires when it crosses 2 %. diff --git a/harnesses/hyperliquid-frontends/builders.json b/harnesses/hyperliquid-frontends/builders.json new file mode 100644 index 00000000..c6f8f0ec --- /dev/null +++ b/harnesses/hyperliquid-frontends/builders.json @@ -0,0 +1,58 @@ +[ + { + "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 (primary address). Documented secondary: 0x4fe1141b9066f3777f4bd4d4ac9d216173031dc1." + } +] diff --git a/harnesses/hyperliquid-frontends/cmd/script/fetcher.go b/harnesses/hyperliquid-frontends/cmd/script/fetcher.go new file mode 100644 index 00000000..f04cc952 --- /dev/null +++ b/harnesses/hyperliquid-frontends/cmd/script/fetcher.go @@ -0,0 +1,225 @@ +package main + +import ( + "context" + "encoding/csv" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + "github.com/pierrec/lz4/v4" +) + +// processBuilder fetches today's + yesterday's Hyperliquid fills CSV +// for one builder address, aggregates the last complete UTC day, +// updates every Prom metric for that builder, AND upserts each fill +// into the SQLite state so the retention pass later in the cycle +// sees fresh data. Returns the day notional so the caller can +// compute the cross-builder volume share. +// +// The headline window is `yesterday's complete UTC day` (i.e. the +// CSV for date T-1). We switched from rolling-24h because the +// rolling cutoff was unfairly zeroing builders whose flow lands +// early in the UTC day — at 18:00 UTC the cutoff `now - 24h` is +// 18:00 yesterday, so a builder that traded between 00:00-17:00 +// yesterday would show 0. The UTC-day window gives every builder +// the same comparison surface at the cost of 0-24h of staleness, +// which is fine for a fee-quality bench (frontends don't change +// pricing minute-to-minute). Today's CSV is still fetched so the +// SQLite state captures the in-progress day for retention math. +func processBuilder(ctx context.Context, b Builder, state *State) float64 { + now := time.Now().UTC() + + // Hyperliquid publishes the per-day CSV with a variable delay — + // usually T-1 lands within hours of UTC midnight, but the bucket + // has gone 48 h+ without a fresh file (observed 2026-05-31). To + // avoid zeroing every builder on a publish lag, walk back up to + // 3 days and pick the most recent date that returned a non-empty + // CSV. State always upserts from whatever days we did fetch so + // retention math gets the freshest signal regardless. + dates := []time.Time{ + now.AddDate(0, 0, -1), + now.AddDate(0, 0, -2), + now.AddDate(0, 0, -3), + } + var headlineFills []fillRow + var headlineDateKey string + for _, d := range dates { + batch, code, err := fetchDay(ctx, b.Address, d) + hlCSVFetchStatus.WithLabelValues(b.Slug, code).Inc() + if err != nil { + fmt.Printf("[%s] %s: %s err=%v\n", b.Slug, d.Format("20060102"), code, err) + continue + } + // State always gets every fill — retention math wants the + // long history of unique users, not just the headline day. + for _, f := range batch { + if state != nil && f.User != "" { + if err := state.Upsert(b.Slug, f.User, f.Time.UnixMilli(), f.Px*f.Sz); err != nil { + fmt.Printf("[%s] state upsert error: %v\n", b.Slug, err) + } + } + } + // Pick the first day that has at least one fill — that's + // the freshest "complete UTC day" for this builder. + if headlineDateKey == "" && len(batch) > 0 { + headlineFills = batch + headlineDateKey = d.Format("20060102") + } + } + + var ( + notionalUSD float64 + builderFeeUSD float64 + fillCount int + users = make(map[string]struct{}) + ) + for _, f := range headlineFills { + notionalUSD += f.Px * f.Sz + builderFeeUSD += f.BuilderFee + fillCount++ + if f.User != "" { + users[f.User] = struct{}{} + } + } + + hlFillsTotal.WithLabelValues(b.Slug).Set(float64(fillCount)) + hlVolumeUSD24h.WithLabelValues(b.Slug).Set(notionalUSD) + hlUsers24h.WithLabelValues(b.Slug).Set(float64(len(users))) + + if notionalUSD > 0 { + hlEffectiveFeeBps.WithLabelValues(b.Slug).Set(builderFeeUSD / notionalUSD * 10_000) + } + if len(users) > 0 { + hlFeesPerUserUSD.WithLabelValues(b.Slug).Set(builderFeeUSD / float64(len(users))) + } + + fmt.Printf("[%s] day=%s fills=%d notional=$%.0f fees=$%.2f users=%d eff=%.2fbps\n", + b.Slug, headlineDateKey, fillCount, notionalUSD, builderFeeUSD, len(users), + safeRatio(builderFeeUSD, notionalUSD)*10_000) + return notionalUSD +} + +type fillRow struct { + Time time.Time + User string + Coin string + Side string + Px float64 + Sz float64 + BuilderFee float64 +} + +// fetchDay GETs the per-day CSV dump, LZ4-decompresses, parses into +// fillRow slices. Returns (rows, http_code_label, err). The +// http_code_label is the string we want to label the +// `hl_frontend_csv_fetch_status_total` counter with — "200" for OK, +// "403" for "no fills that day" (Hyperliquid bucket returns 403 when +// the date file doesn't exist), "5xx" for server errors, "error" for +// transport-level failures. +func fetchDay(ctx context.Context, address string, day time.Time) ([]fillRow, string, error) { + url := fmt.Sprintf( + "https://stats-data.hyperliquid.xyz/Mainnet/builder_fills/%s/%s.csv.lz4", + strings.ToLower(address), + day.Format("20060102"), + ) + reqCtx, cancel := context.WithTimeout(ctx, httpTimeout) + defer cancel() + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, url, nil) + if err != nil { + return nil, "error", err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, "error", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + if resp.StatusCode >= 500 { + return nil, "5xx", fmt.Errorf("http %d", resp.StatusCode) + } + return nil, fmt.Sprintf("%d", resp.StatusCode), nil + } + + dec := lz4.NewReader(resp.Body) + rd := csv.NewReader(dec) + rd.FieldsPerRecord = -1 + header, err := rd.Read() + if err != nil { + return nil, "200", fmt.Errorf("header: %w", err) + } + col := map[string]int{} + for i, h := range header { + col[strings.TrimSpace(h)] = i + } + required := []string{"time", "user", "px", "sz", "builder_fee"} + for _, r := range required { + if _, ok := col[r]; !ok { + return nil, "200", fmt.Errorf("missing column %q in header %v", r, header) + } + } + + var rows []fillRow + for { + rec, err := rd.Read() + if err == io.EOF { + break + } + if err != nil { + return rows, "200", fmt.Errorf("row: %w", err) + } + if len(rec) < len(header) { + continue + } + t, err := parseFillTime(rec[col["time"]]) + if err != nil { + continue + } + px, _ := strconv.ParseFloat(rec[col["px"]], 64) + sz, _ := strconv.ParseFloat(rec[col["sz"]], 64) + fee, _ := strconv.ParseFloat(rec[col["builder_fee"]], 64) + rows = append(rows, fillRow{ + Time: t, + User: strings.ToLower(rec[col["user"]]), + Coin: safeCol(rec, col, "coin"), + Side: safeCol(rec, col, "side"), + Px: px, + Sz: sz, + BuilderFee: fee, + }) + } + return rows, "200", nil +} + +func safeCol(rec []string, col map[string]int, k string) string { + i, ok := col[k] + if !ok || i >= len(rec) { + return "" + } + return rec[i] +} + +// parseFillTime accepts the two timestamp shapes Hyperliquid has +// shipped over the lifetime of the bucket: Unix-millis integers and +// RFC3339 strings. Both have been observed in the wild. +func parseFillTime(raw string) (time.Time, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return time.Time{}, fmt.Errorf("empty time") + } + if ms, err := strconv.ParseInt(raw, 10, 64); err == nil { + return time.UnixMilli(ms).UTC(), nil + } + return time.Parse(time.RFC3339Nano, raw) +} + +func safeRatio(a, b float64) float64 { + if b == 0 { + return 0 + } + return a / b +} diff --git a/harnesses/hyperliquid-frontends/cmd/script/main.go b/harnesses/hyperliquid-frontends/cmd/script/main.go new file mode 100644 index 00000000..586ed70a --- /dev/null +++ b/harnesses/hyperliquid-frontends/cmd/script/main.go @@ -0,0 +1,146 @@ +package main + +// Hyperliquid frontends quality benchmark. +// +// Pulls the per-builder, per-day fills CSV that the Hyperliquid team +// publishes at https://stats-data.hyperliquid.xyz/Mainnet/builder_fills/ +// then computes three quality metrics per builder: +// +// - effective_fee_bps = sum(builder_fee) / sum(notional) * 10000 +// - fees_per_user_usd = sum(builder_fee) / count(distinct user) +// - volume_usd_24h = sum(px * sz) +// +// 30-day fee discipline is NOT computed here — it falls out for free +// via `stddev_over_time(hl_frontend_effective_fee_bps[30d])` in +// Prometheus once the time-series accumulates. +// +// MVP shape — wire it up, point at the public bucket, expose Prom on +// :2112. Builder address registry lives at ../builders.json so a new +// builder is added without code change. + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + "time" +) + +const ( + scrapeInterval = 3600 * time.Second // poll each builder hourly + httpTimeout = 30 * time.Second +) + +func main() { + fmt.Println("=== Hyperliquid Frontends Quality Harness ===") + fmt.Println("OpenChainBench - builder-code effective fee + $/user + volume.") + fmt.Println() + + registry, err := loadRegistry("./builders.json") + if err != nil { + fmt.Printf("[fatal] load registry: %v\n", err) + os.Exit(1) + } + fmt.Printf("Registry: %d builders\n", len(registry)) + for _, b := range registry { + fmt.Printf(" · %-15s %s\n", b.Slug, b.Address) + } + fmt.Println() + + state, err := OpenState() + if err != nil { + fmt.Printf("[fatal] open state: %v\n", err) + os.Exit(1) + } + defer state.Close() + + fmt.Println("Metrics server: :2112/metrics") + fmt.Println() + + // Hardcoded :2112 per the OCB Railway scrape convention. We + // deliberately ignore Railway's $PORT (would move the listener + // away from where the shared Prom expects it). METRICS_ADDR is + // a local-dev escape hatch only — never set in production. + addr := os.Getenv("METRICS_ADDR") + if addr == "" { + addr = ":2112" + } + go func() { + if err := StartMetricsServer(addr); err != nil { + fmt.Printf("[fatal] metrics server: %v\n", err) + os.Exit(1) + } + }() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // First cycle fires after a 5 s warmup so /metrics has data on the + // first scrape; subsequent cycles run on the configured interval. + warmup := time.NewTimer(5 * time.Second) + defer warmup.Stop() + ticker := time.NewTicker(scrapeInterval) + defer ticker.Stop() + + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt, syscall.SIGTERM) + + for { + select { + case <-warmup.C: + runCycle(ctx, registry, state) + case <-ticker.C: + runCycle(ctx, registry, state) + case s := <-sig: + fmt.Printf("\n[shutdown] received %v\n", s) + cancel() + return + } + } +} + +func runCycle(ctx context.Context, registry []Builder, state *State) { + cycleStart := time.Now() + // Per-builder pass: fetch CSVs, update gauges, upsert into state. + var totalNotional float64 + perBuilderNotional := make(map[string]float64, len(registry)) + for _, b := range registry { + select { + case <-ctx.Done(): + return + default: + } + notional := processBuilder(ctx, b, state) + perBuilderNotional[b.Slug] = notional + totalNotional += notional + } + // Volume share + retention pass once state is up to date. + for _, b := range registry { + if totalNotional > 0 { + hlVolumeSharePct.WithLabelValues(b.Slug).Set(perBuilderNotional[b.Slug] / totalNotional * 100) + } + for _, days := range []int{7, 30} { + ret, cohort, err := state.Retention(ctx, b.Slug, days) + if err != nil { + fmt.Printf("[%s] retention(%dd) error: %v\n", b.Slug, days, err) + continue + } + switch days { + case 7: + hlD7Retention.WithLabelValues(b.Slug).Set(ret) + hlD7CohortSize.WithLabelValues(b.Slug).Set(float64(cohort)) + case 30: + hlD30Retention.WithLabelValues(b.Slug).Set(ret) + hlD30CohortSize.WithLabelValues(b.Slug).Set(float64(cohort)) + } + } + } + // Prune anything past our 90-day horizon so the SQLite file stays + // bounded. 90d > our longest retention window (D30) with 2x safety + // margin for backfill / replay scenarios. + if n, err := state.PruneOlderThan(90); err == nil && n > 0 { + fmt.Printf("[state] pruned %d rows older than 90d\n", n) + } + fmt.Printf("[cycle] done in %s\n", time.Since(cycleStart).Round(time.Millisecond)) +} diff --git a/harnesses/hyperliquid-frontends/cmd/script/metrics.go b/harnesses/hyperliquid-frontends/cmd/script/metrics.go new file mode 100644 index 00000000..5d9e074a --- /dev/null +++ b/harnesses/hyperliquid-frontends/cmd/script/metrics.go @@ -0,0 +1,136 @@ +package main + +import ( + "net/http" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +// Prom metrics for the Hyperliquid frontends quality bench. Names are +// prefixed `hl_frontend_` so the OCB MCP allowlist needs one entry +// (`hl_frontend_`) for every metric this harness emits. + +var ( + hlEffectiveFeeBps = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "hl_frontend_effective_fee_bps", + Help: "Volume-weighted effective builder fee in basis points over the last 24h: sum(builder_fee) / sum(notional) * 10000.", + }, + []string{"builder"}, + ) + + hlFeesPerUserUSD = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "hl_frontend_fees_per_user_usd", + Help: "USD fees captured per unique trader over the last 24h: sum(builder_fee_usd) / count(distinct user).", + }, + []string{"builder"}, + ) + + hlVolumeUSD24h = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "hl_frontend_volume_usd_24h", + Help: "Total notional USD volume routed via this builder in the last 24h. Secondary signal — not the headline.", + }, + []string{"builder"}, + ) + + hlUsers24h = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "hl_frontend_users_24h", + Help: "Count of unique trader addresses attributed to this builder in the last 24h.", + }, + []string{"builder"}, + ) + + hlFillsTotal = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "hl_frontend_fills_total", + Help: "Count of attributed fills observed in the last 24h. Drives the leaderboard sample_size column.", + }, + []string{"builder"}, + ) + + hlUnattributedShare = promauto.NewGauge( + prometheus.GaugeOpts{ + Name: "hl_frontend_unattributed_share_pct", + Help: "Share of Hyperliquid builder-fee volume from addresses NOT in our registry. >2% should trigger a registry-update review.", + }, + ) + + hlRegistryAge = promauto.NewGauge( + prometheus.GaugeOpts{ + Name: "hl_frontend_registry_age_seconds", + Help: "Seconds since the builders.json file was last modified on disk. Surfaces stale-registry drift.", + }, + ) + + hlCSVFetchStatus = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "hl_frontend_csv_fetch_status_total", + Help: "Outcome of each fetch of the per-day Hyperliquid fills CSV, by HTTP code (200, 403=no fills that day, 5xx, error).", + }, + []string{"builder", "code"}, + ) + + // Cohort retention — the unique OCB edge. Of the users whose first + // observed fill for a builder lands in the 24h window {7,30} days + // ago, the fraction that traded again in the last 24h. Requires + // the SQLite state layer because no public source publishes it. + hlD7Retention = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "hl_frontend_d7_retention_pct", + Help: "D7 cohort retention in percent: of users who first traded via this builder 7 days ago, fraction that traded again in the last 24h.", + }, + []string{"builder"}, + ) + + hlD30Retention = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "hl_frontend_d30_retention_pct", + Help: "D30 cohort retention in percent: of users who first traded via this builder 30 days ago, fraction that traded again in the last 24h.", + }, + []string{"builder"}, + ) + + hlD7CohortSize = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "hl_frontend_d7_cohort_size", + Help: "Number of users in the D7 cohort. Low cohort sizes (<50) make the retention percentage statistically noisy.", + }, + []string{"builder"}, + ) + + hlD30CohortSize = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "hl_frontend_d30_cohort_size", + Help: "Number of users in the D30 cohort.", + }, + []string{"builder"}, + ) + + // Volume share computed across builders in the registry. Every + // other dashboard has this — we expose it as secondary so the + // page still surfaces the number readers expect to see. + hlVolumeSharePct = promauto.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "hl_frontend_volume_share_pct", + Help: "Share in percent of the last 24h notional volume across builders in our registry. Coverage gap (= volume from unregistered addresses) surfaces in hl_frontend_unattributed_share_pct.", + }, + []string{"builder"}, + ) +) + +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.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("hyperliquid-frontends harness · OpenChainBench")) + }) + return http.ListenAndServe(addr, mux) +} diff --git a/harnesses/hyperliquid-frontends/cmd/script/registry.go b/harnesses/hyperliquid-frontends/cmd/script/registry.go new file mode 100644 index 00000000..af26d153 --- /dev/null +++ b/harnesses/hyperliquid-frontends/cmd/script/registry.go @@ -0,0 +1,40 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "strings" +) + +// Builder maps a curated frontend slug to its Hyperliquid builder-code +// EVM address. The address is what the on-chain `ApproveBuilderFee` +// action registers and what every order with that builder embedded +// surfaces in the per-day CSV dumps. +type Builder struct { + Slug string `json:"slug"` + Name string `json:"name"` + Address string `json:"address"` + ValidFrom string `json:"valid_from"` + Notes string `json:"notes,omitempty"` +} + +func loadRegistry(path string) ([]Builder, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + var entries []Builder + if err := json.Unmarshal(raw, &entries); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + for i := range entries { + // Lowercase addresses — the Hyperliquid stats bucket is case + // sensitive (paths only resolve when the address is lower). + entries[i].Address = strings.ToLower(entries[i].Address) + } + if len(entries) == 0 { + return nil, fmt.Errorf("empty registry: %s", path) + } + return entries, nil +} diff --git a/harnesses/hyperliquid-frontends/cmd/script/state.go b/harnesses/hyperliquid-frontends/cmd/script/state.go new file mode 100644 index 00000000..c967dfa5 --- /dev/null +++ b/harnesses/hyperliquid-frontends/cmd/script/state.go @@ -0,0 +1,135 @@ +package main + +import ( + "context" + "database/sql" + "fmt" + "os" + "path/filepath" + "time" + + _ "modernc.org/sqlite" +) + +// State layer = pure-Go SQLite under $STATE_DIR/state.db (defaults to +// ./state.db). One table records (builder, user) first-seen / last-seen +// timestamps so we can compute cohort retention without re-parsing the +// full CSV history every cycle. +// +// This is the differentiator vs every other Hyperliquid frontends +// dashboard — nobody publishes D7/D30 retention per builder cleanly +// because nobody bothers maintaining the state. Five lines of SQL +// plus a single 100 MB file on a Railway volume gets us a metric the +// rest of the field can't match. + +type State struct { + db *sql.DB +} + +func OpenState() (*State, error) { + dir := os.Getenv("STATE_DIR") + if dir == "" { + dir = "." + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("mkdir state: %w", err) + } + path := filepath.Join(dir, "state.db") + db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)") + if err != nil { + return nil, fmt.Errorf("open sqlite %s: %w", path, err) + } + if _, err := db.Exec(` + CREATE TABLE IF NOT EXISTS user_activity ( + builder TEXT NOT NULL, + user TEXT NOT NULL, + first_seen_ms INTEGER NOT NULL, + last_seen_ms INTEGER NOT NULL, + fills INTEGER NOT NULL DEFAULT 0, + notional_usd REAL NOT NULL DEFAULT 0, + PRIMARY KEY (builder, user) + ); + CREATE INDEX IF NOT EXISTS idx_user_activity_first_seen + ON user_activity(builder, first_seen_ms); + CREATE INDEX IF NOT EXISTS idx_user_activity_last_seen + ON user_activity(builder, last_seen_ms); + `); err != nil { + return nil, fmt.Errorf("schema: %w", err) + } + fmt.Printf("[state] sqlite opened at %s\n", path) + return &State{db: db}, nil +} + +// Upsert one observation. Idempotent: re-running the same CSV is safe +// because first_seen is `MIN(existing, candidate)` and last_seen is +// `MAX(existing, candidate)`. Fills + notional aggregate via SUM but +// we de-dupe on the caller side per cycle (only NEW rows passed in). +func (s *State) Upsert(builder, user string, tsMs int64, notionalUSD float64) error { + _, err := s.db.Exec(` + INSERT INTO user_activity (builder, user, first_seen_ms, last_seen_ms, fills, notional_usd) + VALUES (?, ?, ?, ?, 1, ?) + ON CONFLICT(builder, user) DO UPDATE SET + first_seen_ms = MIN(first_seen_ms, excluded.first_seen_ms), + last_seen_ms = MAX(last_seen_ms, excluded.last_seen_ms), + fills = fills + 1, + notional_usd = notional_usd + excluded.notional_usd + `, builder, user, tsMs, tsMs, notionalUSD) + return err +} + +// Retention computes cohort retention for the given builder + window +// in days. Definition: of users whose first_seen_ms falls in the +// 24-hour window starting `days` ago, what fraction had a last_seen_ms +// within the last 24 hours. +// +// cohort = COUNT first_seen ∈ [now - days*24h, now - (days-1)*24h) +// active = COUNT first_seen ∈ that bucket AND last_seen ≥ (now - 24h) +// ratio = active / cohort (0 when cohort empty) +// +// D7 → days=7. D30 → days=30. +func (s *State) Retention(ctx context.Context, builder string, days int) (float64, int, error) { + now := time.Now().UTC().UnixMilli() + cohortStart := now - int64(days)*86_400_000 + cohortEnd := now - int64(days-1)*86_400_000 + activeAfter := now - 86_400_000 + + var cohort, returned int + // COALESCE because SUM(CASE) returns NULL on an empty result + // set (no users in the cohort window). database/sql can't scan + // NULL into a plain int, so we coerce to 0 in SQL. + err := s.db.QueryRowContext(ctx, ` + SELECT + COUNT(*) AS cohort, + COALESCE(SUM(CASE WHEN last_seen_ms >= ? THEN 1 ELSE 0 END), 0) AS returned + FROM user_activity + WHERE builder = ? + AND first_seen_ms >= ? + AND first_seen_ms < ? + `, activeAfter, builder, cohortStart, cohortEnd).Scan(&cohort, &returned) + if err != nil { + return 0, 0, err + } + if cohort == 0 { + return 0, 0, nil + } + return float64(returned) / float64(cohort) * 100, cohort, nil +} + +// PruneOlderThan removes activity rows older than the retention horizon +// so the SQLite file doesn't grow forever. Run once per cycle. +func (s *State) PruneOlderThan(days int) (int64, error) { + cutoff := time.Now().UTC().Add(-time.Duration(days) * 24 * time.Hour).UnixMilli() + res, err := s.db.Exec(`DELETE FROM user_activity WHERE last_seen_ms < ?`, cutoff) + if err != nil { + return 0, err + } + n, _ := res.RowsAffected() + return n, nil +} + +func (s *State) Close() error { + if s == nil || s.db == nil { + return nil + } + return s.db.Close() +} diff --git a/harnesses/hyperliquid-frontends/go.mod b/harnesses/hyperliquid-frontends/go.mod new file mode 100644 index 00000000..2fe449b4 --- /dev/null +++ b/harnesses/hyperliquid-frontends/go.mod @@ -0,0 +1,9 @@ +module github.com/OpenChainBench/OpenChainBench/harnesses/hyperliquid-frontends + +go 1.23 + +require ( + github.com/pierrec/lz4/v4 v4.1.21 + github.com/prometheus/client_golang v1.20.5 + modernc.org/sqlite v1.34.1 +) diff --git a/harnesses/hyperliquid-frontends/go.sum b/harnesses/hyperliquid-frontends/go.sum new file mode 100644 index 00000000..55d3db80 --- /dev/null +++ b/harnesses/hyperliquid-frontends/go.sum @@ -0,0 +1,73 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= +github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= +github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +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= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= +golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= +golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= +modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= +modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= +modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.34.1 h1:u3Yi6M0N8t9yKRDwhXcyp1eS5/ErhPTBggxWFuR6Hfk= +modernc.org/sqlite v1.34.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/harnesses/l1-finality/cmd/script/config.go b/harnesses/l1-finality/cmd/script/config.go index 0bb19fd6..91270f66 100644 --- a/harnesses/l1-finality/cmd/script/config.go +++ b/harnesses/l1-finality/cmd/script/config.go @@ -33,6 +33,11 @@ type ChainConfig struct { // Confirmations to consider a block "finalized" — used by PoW kinds // where finality is probabilistic. Ignored otherwise. Confirmations int + // Optional per-chain minimum poll interval. Used when an upstream API + // has a daily request quota (Koios free tier on Cardano is 5k/day — + // our default 10s cadence burns through it in 14h, then the IP gets + // blocked). 0 = use the global Interval. + MinIntervalSeconds int } type Config struct { @@ -93,7 +98,11 @@ func loadConfig() *Config { Slug: "litecoin", Name: "Litecoin", Kind: KindBitcoinLike, - RPCURL: getenvDefault("RPC_LITECOIN", "https://api.blockchair.com/litecoin"), + // Switched from blockchair (free-tier IP blacklist hit on + // 2026-05-31, 100% 430 responses) to litecoinspace.org's + // Esplora-compatible API. Fewer calls per tick (1 vs 2) and + // no auth required. + RPCURL: getenvDefault("RPC_LITECOIN", "https://litecoinspace.org/api"), // Coinbase deposit standard for LTC. The April 2026 13-block // MWEB reorg made 6 unsafe; 12 is the post-incident floor. Confirmations: 12, @@ -114,6 +123,13 @@ func loadConfig() *Config { // Picking 15 — protects above k_practical, well below the // theoretical k = 2160 (~12h) which no production actor uses. Confirmations: 15, + // Koios free tier: 5,000 req/day per IP. Our 2-call cadence at + // the global 10s interval = 17,280 req/day — burns the quota in + // ~7h then the IP gets blocked. 60s cadence = 2,880 req/day, + // safely under quota. Cardano blocks are ~20s, so per-minute + // resolution is fine for a finality-lag metric that updates + // over multi-minute windows anyway (15 confs × 20s = 5 min). + MinIntervalSeconds: 60, }, }, } diff --git a/harnesses/l1-finality/cmd/script/main.go b/harnesses/l1-finality/cmd/script/main.go index 7566676f..09696a9d 100644 --- a/harnesses/l1-finality/cmd/script/main.go +++ b/harnesses/l1-finality/cmd/script/main.go @@ -51,6 +51,36 @@ func main() { wg.Wait() } +// lastFetchAt tracks the last successful (or attempted) fetch per chain slug. +// Used to honor ChainConfig.MinIntervalSeconds — chains with an upstream API +// quota (Cardano via Koios free tier) skip the global 10 s tick to avoid +// burning the daily request budget. Mutex-protected; the global ticker fires +// concurrent goroutines so the map read+write needs to be serialized. +var ( + lastFetchAt = make(map[string]time.Time) + lastFetchAtMu sync.Mutex +) + +func shouldSkip(ch ChainConfig, now time.Time) bool { + if ch.MinIntervalSeconds <= 0 { + return false + } + lastFetchAtMu.Lock() + defer lastFetchAtMu.Unlock() + last, ok := lastFetchAt[ch.Slug] + if !ok { + // first tick — let it through and stamp now so the next tick + // honors the per-chain minimum. + lastFetchAt[ch.Slug] = now + return false + } + if now.Sub(last) < time.Duration(ch.MinIntervalSeconds)*time.Second { + return true + } + lastFetchAt[ch.Slug] = now + return false +} + func runRefreshLoop(cfg *Config, stop <-chan struct{}) { tick := time.NewTicker(cfg.Interval) defer tick.Stop() @@ -68,8 +98,12 @@ func runRefreshLoop(cfg *Config, stop <-chan struct{}) { } func fetchAll(cfg *Config) { + now := time.Now() var wg sync.WaitGroup for _, ch := range cfg.Chains { + if shouldSkip(ch, now) { + continue + } ch := ch wg.Add(1) go func() { diff --git a/harnesses/l1-finality/cmd/script/pow.go b/harnesses/l1-finality/cmd/script/pow.go index 8b563b08..d7974a79 100644 --- a/harnesses/l1-finality/cmd/script/pow.go +++ b/harnesses/l1-finality/cmd/script/pow.go @@ -5,28 +5,24 @@ import ( "fmt" "io" "net/http" - "strconv" "time" ) -// PoW chains: probabilistic finality, "finalized" === N confirmations -// back. We use blockchair's free public API for the Bitcoin-family -// (Litecoin works the same way; Bitcoin would too if added). +// PoW chains: probabilistic finality, "finalized" === N confirmations back. +// +// We use an Esplora-compatible block explorer (litecoinspace.org for Litecoin; +// mempool.space-style API works the same way for Bitcoin if added later). +// `GET /api/blocks` returns the last ~15 blocks with `height` and `timestamp`, +// so a single call covers BOTH the latest block AND the finalized one (12 +// confirmations back for Litecoin). This replaces the old 2-call blockchair +// flow that was blacklisted by free-tier IP rate limits. +// +// API spec: https://github.com/Blockstream/esplora/blob/master/API.md -type blockchairStats struct { - Data struct { - Blocks int64 `json:"blocks"` - BestBlockTime string `json:"best_block_time"` // "2026-05-06 19:01:13" UTC - } `json:"data"` -} - -type blockchairBlock struct { - Data map[string]struct { - Block struct { - ID int64 `json:"id"` - Time string `json:"time"` // UTC string - } `json:"block"` - } `json:"data"` +type esploraBlock struct { + ID string `json:"id"` // block hash + Height int64 `json:"height"` + Timestamp int64 `json:"timestamp"` // unix seconds, UTC } func fetchBitcoinLike(ch ChainConfig) FinalitySample { @@ -34,71 +30,71 @@ func fetchBitcoinLike(ch ChainConfig) FinalitySample { start := time.Now() client := &http.Client{Timeout: 10 * time.Second} - stats, err := blockchairStatsFetch(client, ch.RPCURL) + blocks, err := esploraRecentBlocks(client, ch.RPCURL) + s.FetchLatencyMs = time.Since(start).Milliseconds() if err != nil { - s.Err = fmt.Sprintf("stats: %v", err) - s.FetchLatencyMs = time.Since(start).Milliseconds() + s.Err = fmt.Sprintf("blocks: %v", err) return s } - latestHeight := stats.Data.Blocks - 1 // blocks count is 1-indexed - finalHeight := latestHeight - int64(ch.Confirmations) - if finalHeight < 0 { - finalHeight = 0 - } - - finalBlock, err := blockchairBlockAt(client, ch.RPCURL, finalHeight) - if err != nil { - s.Err = fmt.Sprintf("final_block (h=%d): %v", finalHeight, err) - s.FetchLatencyMs = time.Since(start).Milliseconds() + if len(blocks) == 0 { + s.Err = "blocks: empty" return s } - s.FetchLatencyMs = time.Since(start).Milliseconds() - latestTs, err := time.Parse("2006-01-02 15:04:05", stats.Data.BestBlockTime) - if err != nil { - s.Err = fmt.Sprintf("parse_latest_ts: %v", err) - return s - } - finalTs, err := time.Parse("2006-01-02 15:04:05", finalBlock.Time) - if err != nil { - s.Err = fmt.Sprintf("parse_final_ts: %v", err) + tip := blocks[0] + // If the returned window is shorter than the desired confirmations + // depth, paginate one more call using /api/blocks/. Litecoin + // (12 conf) is satisfied by the default 15-block window. + finalIdx := ch.Confirmations + if finalIdx >= len(blocks) { + more, err := esploraBlocksFrom(client, ch.RPCURL, tip.Height-int64(ch.Confirmations)) + if err != nil { + s.Err = fmt.Sprintf("blocks_at_h-%d: %v", ch.Confirmations, err) + return s + } + if len(more) == 0 { + s.Err = "blocks_at_h: empty" + return s + } + // /api/blocks/ returns blocks <= H, newest first; index 0 is the + // finalized block we want. + final := more[0] + s.LatestBlock = tip.Height + s.FinalizedBlock = final.Height + s.BlockLag = s.LatestBlock - s.FinalizedBlock + s.LagSeconds = float64(tip.Timestamp - final.Timestamp) + if s.LagSeconds < 0 { + s.LagSeconds = 0 + } return s } - s.LatestBlock = latestHeight - s.FinalizedBlock = finalHeight + final := blocks[finalIdx] + s.LatestBlock = tip.Height + s.FinalizedBlock = final.Height s.BlockLag = s.LatestBlock - s.FinalizedBlock - s.LagSeconds = latestTs.Sub(finalTs).Seconds() + s.LagSeconds = float64(tip.Timestamp - final.Timestamp) if s.LagSeconds < 0 { s.LagSeconds = 0 } return s } -func blockchairStatsFetch(client *http.Client, base string) (*blockchairStats, error) { - resp, err := client.Get(base + "/stats") - if err != nil { - return nil, 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)) - } - var parsed blockchairStats - if err := json.Unmarshal(body, &parsed); err != nil { - return nil, fmt.Errorf("parse: %v", err) - } - return &parsed, nil +// esploraRecentBlocks calls GET /api/blocks — returns the most recent ~15 +// blocks, newest first. Each entry has height + unix timestamp. +func esploraRecentBlocks(client *http.Client, base string) ([]esploraBlock, error) { + return esploraGetBlocks(client, base+"/blocks") } -type blockchairSimpleBlock struct { - ID int64 `json:"id"` - Time string `json:"time"` +// esploraBlocksFrom calls GET /api/blocks/ — returns blocks <= height, +// newest first. Used when the desired confirmations depth exceeds the default +// 15-block window. +func esploraBlocksFrom(client *http.Client, base string, height int64) ([]esploraBlock, error) { + return esploraGetBlocks(client, fmt.Sprintf("%s/blocks/%d", base, height)) } -func blockchairBlockAt(client *http.Client, base string, height int64) (*blockchairSimpleBlock, error) { - resp, err := client.Get(base + "/dashboards/block/" + strconv.FormatInt(height, 10)) +func esploraGetBlocks(client *http.Client, url string) ([]esploraBlock, error) { + resp, err := client.Get(url) if err != nil { return nil, err } @@ -107,12 +103,9 @@ func blockchairBlockAt(client *http.Client, base string, height int64) (*blockch if resp.StatusCode != 200 { return nil, fmt.Errorf("status_%d: %s", resp.StatusCode, truncate(string(body), 200)) } - var parsed blockchairBlock - if err := json.Unmarshal(body, &parsed); err != nil { + var blocks []esploraBlock + if err := json.Unmarshal(body, &blocks); err != nil { return nil, fmt.Errorf("parse: %v", err) } - for _, v := range parsed.Data { - return &blockchairSimpleBlock{ID: v.Block.ID, Time: v.Block.Time}, nil - } - return nil, fmt.Errorf("no_block_in_response: %s", truncate(string(body), 200)) + return blocks, nil } diff --git a/harnesses/l2-block-time/cmd/script/sequencer_ws.go b/harnesses/l2-block-time/cmd/script/sequencer_ws.go index e46f39e0..26d59f69 100644 --- a/harnesses/l2-block-time/cmd/script/sequencer_ws.go +++ b/harnesses/l2-block-time/cmd/script/sequencer_ws.go @@ -5,6 +5,7 @@ import ( "fmt" "strconv" "strings" + "sync/atomic" "time" "github.com/gorilla/websocket" @@ -30,6 +31,14 @@ const ( // Sanity bound — never emit a single sample larger than this. Drops // the natural outlier of a reconnect gap from polluting p50. maxSampleMs = 5 * 60 * 1000 // 5 min + // Max time without receiving a newHead event before we force a + // reconnect. Important because the pong handler bumps SetReadDeadline, + // so a provider that keeps sending pongs but silently drops our + // subscription (observed on publicnode → scroll: 0 reconnects in 1h + // while samples_total stuck at 23,887) wedges the goroutine forever. + // All L2s in the panel have block-time < 15s, so 90s of silence is + // unambiguously broken. + headWatchdog = 90 * time.Second ) type rpcReq struct { @@ -103,13 +112,20 @@ func runChain(ch L2Chain) error { blockTimeHealth.WithLabelValues(ch.Slug).Set(1) fmt.Printf("[%s] connected, subscribed to newHeads\n", ch.Slug) - pingDone := make(chan struct{}) + done := make(chan struct{}) + // lastHeadUnixNano is read by the watchdog and written on every new head. + // Atomic int64 keeps the watchdog lock-free; initial value = connect time + // so the watchdog gives the subscription a normal head-window grace + // period before counting silence. + var lastHeadUnixNano atomic.Int64 + lastHeadUnixNano.Store(time.Now().UnixNano()) + go func() { t := time.NewTicker(pingEvery) defer t.Stop() for { select { - case <-pingDone: + case <-done: return case <-t.C: _ = conn.WriteControl( @@ -120,7 +136,30 @@ func runChain(ch L2Chain) error { } } }() - defer close(pingDone) + // Head watchdog. The pong handler extends SetReadDeadline on every pong, + // so the read deadline alone can't catch a provider that keeps the WS + // heartbeat alive while silently dropping the subscription. Check every + // 15s: if we haven't received a head in headWatchdog, slam the connection + // closed → ReadMessage returns an error → runChain returns → outer loop + // bumps the reconnect counter and dials again. + go func() { + t := time.NewTicker(15 * time.Second) + defer t.Stop() + for { + select { + case <-done: + return + case now := <-t.C: + silent := now.Sub(time.Unix(0, lastHeadUnixNano.Load())) + if silent > headWatchdog { + fmt.Printf("[%s] watchdog: no head for %s — forcing reconnect\n", ch.Slug, silent.Round(time.Second)) + _ = conn.Close() + return + } + } + } + }() + defer close(done) conn.SetPongHandler(func(string) error { _ = conn.SetReadDeadline(time.Now().Add(readDeadline)) @@ -159,6 +198,7 @@ func runChain(ch L2Chain) error { } now := time.Now() + lastHeadUnixNano.Store(now.UnixNano()) if !lastSeen.IsZero() { deltaMs := float64(now.Sub(lastSeen).Milliseconds()) if deltaMs > 0 && deltaMs < maxSampleMs { diff --git a/harnesses/metadata-coverage/README.md b/harnesses/metadata-coverage/README.md index 403feca4..1ac3850b 100644 --- a/harnesses/metadata-coverage/README.md +++ b/harnesses/metadata-coverage/README.md @@ -4,8 +4,6 @@ Real-time monitoring tool for tracking blockchain data indexation latency across multiple aggregators. -**[Live Dashboard](https://grafana-production-dc86.up.railway.app/)** - ## How It Works diff --git a/harnesses/metadata-coverage/alertmanager/Dockerfile b/harnesses/metadata-coverage/alertmanager/Dockerfile index 2c74ae71..c7169802 100644 --- a/harnesses/metadata-coverage/alertmanager/Dockerfile +++ b/harnesses/metadata-coverage/alertmanager/Dockerfile @@ -1,12 +1,15 @@ FROM prom/alertmanager:latest -# Copy AlertManager configuration +# Config carries __SLACK_WEBHOOK_URL__; entrypoint.sh sed-substitutes +# it from $SLACK_WEBHOOK_URL at container start. Keeps the real webhook +# URL out of the repo and out of the image layer. COPY alertmanager.yml /etc/alertmanager/alertmanager.yml +COPY entrypoint.sh /entrypoint.sh + +USER root +RUN chmod +x /entrypoint.sh +USER nobody -# Expose AlertManager port EXPOSE 9093 -# Run AlertManager -CMD ["--config.file=/etc/alertmanager/alertmanager.yml", \ - "--storage.path=/alertmanager", \ - "--log.level=debug"] +ENTRYPOINT ["/entrypoint.sh"] diff --git a/harnesses/metadata-coverage/alertmanager/alertmanager.yml b/harnesses/metadata-coverage/alertmanager/alertmanager.yml index 6ea7ebaa..a498dc05 100644 --- a/harnesses/metadata-coverage/alertmanager/alertmanager.yml +++ b/harnesses/metadata-coverage/alertmanager/alertmanager.yml @@ -9,9 +9,12 @@ route: receiver: 'slack-webhook' receivers: + # __SLACK_WEBHOOK_URL__ is substituted at container startup by entrypoint.sh + # from the SLACK_WEBHOOK_URL env var (see alertmanager/Dockerfile + entrypoint.sh). + # Set SLACK_WEBHOOK_URL in your Railway / .env to your real webhook before deploy. - name: 'slack-webhook' webhook_configs: - - url: 'https://agent-slack-production.up.railway.app/webhook/grafana' + - url: '__SLACK_WEBHOOK_URL__' send_resolved: true inhibit_rules: diff --git a/harnesses/metadata-coverage/alertmanager/entrypoint.sh b/harnesses/metadata-coverage/alertmanager/entrypoint.sh new file mode 100755 index 00000000..425532d9 --- /dev/null +++ b/harnesses/metadata-coverage/alertmanager/entrypoint.sh @@ -0,0 +1,18 @@ +#!/bin/sh +# Substitute __SLACK_WEBHOOK_URL__ in the config from $SLACK_WEBHOOK_URL, +# then exec alertmanager. Writes to /tmp so the source (which may be a +# read-only mounted volume in docker-compose) is never modified. +set -e + +if [ -z "${SLACK_WEBHOOK_URL:-}" ]; then + echo "WARN: SLACK_WEBHOOK_URL not set — slack-webhook receiver will get a placeholder URL and silently fail to deliver." >&2 + SLACK_WEBHOOK_URL="https://example.invalid/no-webhook-set" +fi + +sed "s|__SLACK_WEBHOOK_URL__|${SLACK_WEBHOOK_URL}|g" \ + /etc/alertmanager/alertmanager.yml > /tmp/alertmanager.yml + +exec /bin/alertmanager \ + --config.file=/tmp/alertmanager.yml \ + --storage.path=/alertmanager \ + --log.level=debug diff --git a/harnesses/metadata-coverage/docker-compose.yml b/harnesses/metadata-coverage/docker-compose.yml index f011653a..b47bc3ec 100644 --- a/harnesses/metadata-coverage/docker-compose.yml +++ b/harnesses/metadata-coverage/docker-compose.yml @@ -36,15 +36,14 @@ services: - monitor alertmanager: - image: prom/alertmanager:latest + build: + context: ./alertmanager + dockerfile: Dockerfile container_name: alertmanager ports: - "9093:9093" - volumes: - - ./monitoring/alertmanager.yml:/etc/alertmanager/alertmanager.yml - command: - - '--config.file=/etc/alertmanager/alertmanager.yml' - - '--storage.path=/alertmanager' + environment: + - SLACK_WEBHOOK_URL=${SLACK_WEBHOOK_URL} networks: - monitoring restart: unless-stopped diff --git a/harnesses/metadata-coverage/monitoring/alertmanager.yml b/harnesses/metadata-coverage/monitoring/alertmanager.yml index 77b9e263..6c0f533c 100644 --- a/harnesses/metadata-coverage/monitoring/alertmanager.yml +++ b/harnesses/metadata-coverage/monitoring/alertmanager.yml @@ -9,9 +9,12 @@ route: receiver: 'slack-webhook' receivers: + # __SLACK_WEBHOOK_URL__ is substituted at container startup by entrypoint.sh + # from the SLACK_WEBHOOK_URL env var (see alertmanager/Dockerfile + entrypoint.sh). + # Set SLACK_WEBHOOK_URL in your Railway / .env to your real webhook before deploy. - name: 'slack-webhook' webhook_configs: - - url: 'https://agent-slack-production.up.railway.app/webhook/grafana' + - url: '__SLACK_WEBHOOK_URL__' send_resolved: true inhibit_rules: diff --git a/harnesses/metadata-coverage/prometheus/alertmanager.yml b/harnesses/metadata-coverage/prometheus/alertmanager.yml index 77b9e263..6c0f533c 100644 --- a/harnesses/metadata-coverage/prometheus/alertmanager.yml +++ b/harnesses/metadata-coverage/prometheus/alertmanager.yml @@ -9,9 +9,12 @@ route: receiver: 'slack-webhook' receivers: + # __SLACK_WEBHOOK_URL__ is substituted at container startup by entrypoint.sh + # from the SLACK_WEBHOOK_URL env var (see alertmanager/Dockerfile + entrypoint.sh). + # Set SLACK_WEBHOOK_URL in your Railway / .env to your real webhook before deploy. - name: 'slack-webhook' webhook_configs: - - url: 'https://agent-slack-production.up.railway.app/webhook/grafana' + - url: '__SLACK_WEBHOOK_URL__' send_resolved: true inhibit_rules: diff --git a/harnesses/network-coverage/cmd/script/coinpaprika.go b/harnesses/network-coverage/cmd/script/coinpaprika.go new file mode 100644 index 00000000..46bd5696 --- /dev/null +++ b/harnesses/network-coverage/cmd/script/coinpaprika.go @@ -0,0 +1,56 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +const coinpaprikaContractsURL = "https://api.coinpaprika.com/v1/contracts" + +// CoinPaprika's /v1/contracts returns a flat array of platform slugs in +// "{symbol}-{name}" form (e.g. "eth-ethereum", "trx-tron"). It is the closest +// thing they expose to a list of "chains supported for contract lookup". +func fetchCoinPaprika(_ *Config) ProviderResult { + res := ProviderResult{Provider: "coinpaprika"} + + client := &http.Client{Timeout: 15 * time.Second} + req, _ := http.NewRequest("GET", coinpaprikaContractsURL, nil) + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + res.Err = fmt.Sprintf("request_error: %v", err) + return res + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != 200 { + res.Err = fmt.Sprintf("status_%d", resp.StatusCode) + return res + } + + var parsed []string + if err := json.Unmarshal(body, &parsed); err != nil { + res.Err = fmt.Sprintf("parse_error: %v", err) + return res + } + + for _, slug := range parsed { + name := slug + if idx := strings.Index(slug, "-"); idx >= 0 && idx+1 < len(slug) { + name = strings.ReplaceAll(slug[idx+1:], "-", " ") + } + res.Networks = append(res.Networks, Network{ + ChainID: "", + Slug: slug, + Name: name, + }) + } + + return res +} diff --git a/harnesses/network-coverage/cmd/script/coinstats.go b/harnesses/network-coverage/cmd/script/coinstats.go new file mode 100644 index 00000000..344c6ee7 --- /dev/null +++ b/harnesses/network-coverage/cmd/script/coinstats.go @@ -0,0 +1,62 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +const coinstatsBlockchainsURL = "https://openapiv1.coinstats.app/wallet/blockchains" + +// CoinStats /wallet/blockchains returns a flat JSON array of: +// {connectionId, name, icon, chain} +// `connectionId` is the slug (e.g. "binancesmartchain"), `chain` is the +// category enum (e.g. "binance_smart"), `name` is the human label. +type coinstatsBlockchain struct { + ConnectionID string `json:"connectionId"` + Name string `json:"name"` + Chain string `json:"chain"` +} + +func fetchCoinStats(cfg *Config) ProviderResult { + res := ProviderResult{Provider: "coinstats"} + if cfg.CoinStatsAPIKey == "" { + res.Err = "missing_api_key" + return res + } + + client := &http.Client{Timeout: 15 * time.Second} + req, _ := http.NewRequest("GET", coinstatsBlockchainsURL, nil) + req.Header.Set("X-API-KEY", cfg.CoinStatsAPIKey) + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + res.Err = fmt.Sprintf("request_error: %v", err) + return res + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != 200 { + res.Err = fmt.Sprintf("status_%d", resp.StatusCode) + return res + } + + var arr []coinstatsBlockchain + if err := json.Unmarshal(body, &arr); err != nil { + res.Err = fmt.Sprintf("parse_error: %v", err) + return res + } + + for _, b := range arr { + res.Networks = append(res.Networks, Network{ + ChainID: b.Chain, + Slug: b.ConnectionID, + Name: b.Name, + }) + } + return res +} diff --git a/harnesses/network-coverage/cmd/script/config.go b/harnesses/network-coverage/cmd/script/config.go index 0b356452..311ace2b 100644 --- a/harnesses/network-coverage/cmd/script/config.go +++ b/harnesses/network-coverage/cmd/script/config.go @@ -12,6 +12,9 @@ type Config struct { CodexAPIKey string // official Codex Bearer (preferred — no mint, no proxy) CodexSessionCookie string // fallback path: mint JWT from Defined.fi cookie DefinedTokenURL string // optional: pre-minted JWT sidecar + CovalentAPIKey string + CoinStatsAPIKey string + SimDuneAPIKey string // optional — Sim's public endpoint works keyless, but a key avoids rate limits HTTPProxy string RefreshInterval time.Duration IncludeTestnets bool @@ -23,6 +26,9 @@ func loadConfig() *Config { CodexAPIKey: os.Getenv("CODEX_API_KEY"), CodexSessionCookie: os.Getenv("DEFINED_SESSION_COOKIE"), DefinedTokenURL: os.Getenv("DEFINED_TOKEN_SERVICE_URL"), + CovalentAPIKey: os.Getenv("COVALENT_API_KEY"), + CoinStatsAPIKey: os.Getenv("COINSTATS_API_KEY"), + SimDuneAPIKey: os.Getenv("SIM_DUNE_API_KEY"), HTTPProxy: os.Getenv("HTTP_PROXY"), RefreshInterval: 6 * time.Hour, IncludeTestnets: false, @@ -43,7 +49,8 @@ func loadConfig() *Config { } else if c.CodexSessionCookie != "" { codexAuth = "cookie+mint" } - fmt.Printf("Config: refresh=%v, testnets=%v, mobula_key=%v, codex=%s\n", - c.RefreshInterval, c.IncludeTestnets, c.MobulaAPIKey != "", codexAuth) + fmt.Printf("Config: refresh=%v, testnets=%v, mobula_key=%v, codex=%s, covalent_key=%v, coinstats_key=%v, sim_dune_key=%v\n", + c.RefreshInterval, c.IncludeTestnets, c.MobulaAPIKey != "", codexAuth, + c.CovalentAPIKey != "", c.CoinStatsAPIKey != "", c.SimDuneAPIKey != "") return c } diff --git a/harnesses/network-coverage/cmd/script/covalent.go b/harnesses/network-coverage/cmd/script/covalent.go new file mode 100644 index 00000000..740d22b2 --- /dev/null +++ b/harnesses/network-coverage/cmd/script/covalent.go @@ -0,0 +1,83 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +const covalentChainsURL = "https://api.covalenthq.com/v1/chains/" + +// Covalent returns chain_id as a numeric string even for canonical EVM +// chains (e.g. "1" for Ethereum). Keep the raw string to avoid losing +// precision on non-EVM chains where the id can exceed int64 range. +type covalentChain struct { + ChainID string `json:"chain_id"` + Name string `json:"name"` + Label string `json:"label"` + IsTestnet bool `json:"is_testnet"` +} + +type covalentResponse struct { + Data struct { + Items []covalentChain `json:"items"` + } `json:"data"` + Error bool `json:"error"` + ErrorMessage string `json:"error_message"` +} + +func fetchCovalent(cfg *Config) ProviderResult { + res := ProviderResult{Provider: "covalent"} + if cfg.CovalentAPIKey == "" { + res.Err = "missing_api_key" + return res + } + + client := &http.Client{Timeout: 15 * time.Second} + req, _ := http.NewRequest("GET", covalentChainsURL, nil) + req.Header.Set("Authorization", "Bearer "+cfg.CovalentAPIKey) + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + res.Err = fmt.Sprintf("request_error: %v", err) + return res + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != 200 { + res.Err = fmt.Sprintf("status_%d", resp.StatusCode) + return res + } + + var parsed covalentResponse + if err := json.Unmarshal(body, &parsed); err != nil { + res.Err = fmt.Sprintf("parse_error: %v", err) + return res + } + if parsed.Error { + res.Err = "api_error: " + parsed.ErrorMessage + return res + } + + for _, c := range parsed.Data.Items { + if c.IsTestnet && !cfg.IncludeTestnets { + continue + } + label := c.Label + if label == "" { + label = c.Name + } + res.Networks = append(res.Networks, Network{ + ChainID: c.ChainID, + Slug: c.Name, + Name: label, + Testnet: c.IsTestnet, + }) + } + + return res +} diff --git a/harnesses/network-coverage/cmd/script/main.go b/harnesses/network-coverage/cmd/script/main.go index 04c7f24f..bcaab8b0 100644 --- a/harnesses/network-coverage/cmd/script/main.go +++ b/harnesses/network-coverage/cmd/script/main.go @@ -71,6 +71,10 @@ func fetchAll(cfg *Config) { {"geckoterminal", fetchGeckoTerminal}, {"mobula", fetchMobula}, {"codex", fetchCodex}, + {"coinpaprika", fetchCoinPaprika}, + {"dune", fetchSimDune}, + {"covalent", fetchCovalent}, + {"coinstats", fetchCoinStats}, } var wg sync.WaitGroup diff --git a/harnesses/network-coverage/cmd/script/simdune.go b/harnesses/network-coverage/cmd/script/simdune.go new file mode 100644 index 00000000..508abf0d --- /dev/null +++ b/harnesses/network-coverage/cmd/script/simdune.go @@ -0,0 +1,76 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "time" +) + +const simDuneChainsURL = "https://api.sim.dune.com/v1/evm/supported-chains" + +type simDuneChain struct { + Name string `json:"name"` + ChainID int `json:"chain_id"` + Tags []string `json:"tags"` +} + +type simDuneResponse struct { + Chains []simDuneChain `json:"chains"` +} + +// fetchSimDune queries Sim by Dune's public supported-chains endpoint. +// EVM-only by design. Mainnets are tagged "mainnet"; everything else is +// treated as testnet/preview and filtered out unless IncludeTestnets is on. +func fetchSimDune(cfg *Config) ProviderResult { + res := ProviderResult{Provider: "dune"} + + client := &http.Client{Timeout: 15 * time.Second} + req, _ := http.NewRequest("GET", simDuneChainsURL, nil) + req.Header.Set("Accept", "application/json") + if cfg.SimDuneAPIKey != "" { + req.Header.Set("X-Sim-Api-Key", cfg.SimDuneAPIKey) + } + + resp, err := client.Do(req) + if err != nil { + res.Err = fmt.Sprintf("request_error: %v", err) + return res + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != 200 { + res.Err = fmt.Sprintf("status_%d", resp.StatusCode) + return res + } + + var parsed simDuneResponse + if err := json.Unmarshal(body, &parsed); err != nil { + res.Err = fmt.Sprintf("parse_error: %v", err) + return res + } + + for _, c := range parsed.Chains { + isMainnet := false + for _, t := range c.Tags { + if t == "mainnet" { + isMainnet = true + break + } + } + if !isMainnet && !cfg.IncludeTestnets { + continue + } + res.Networks = append(res.Networks, Network{ + ChainID: strconv.Itoa(c.ChainID), + Slug: c.Name, + Name: c.Name, + Testnet: !isMainnet, + }) + } + + return res +} diff --git a/harnesses/rpc-capabilities/README.md b/harnesses/rpc-capabilities/README.md index 38a4327c..3d1ec9aa 100644 --- a/harnesses/rpc-capabilities/README.md +++ b/harnesses/rpc-capabilities/README.md @@ -109,11 +109,43 @@ go run ./cmd/script ## Deployment -Standard OCB-miniapp shape — multi-stage Dockerfile, port 2112, internal-only on Railway, scraped by `openchainbench-monitoring/prometheus/prometheus.yml` via `rpc-capabilities.railway.internal:2112`. +Standard OCB-miniapp shape — multi-stage Dockerfile, port 2112, internal-only on Railway, scraped by `openchainbench-monitoring/prometheus/prometheus.yml`. -## Known limits +### Region labeling + +Every emitted metric carries a `region` label resolved at boot by `main.go: loadRegion()`, in this order: + +1. `$REGION` — explicit override (set when running 3 separate Railway services) +2. `$RAILWAY_REPLICA_REGION` — set automatically by Railway per replica when a single service is scaled across regions via "Add Region". Raw GCP-style slugs (`us-east4-eqdc4a`, `europe-west4-drams5`, `asia-southeast1-eqsg3a`) are normalized to the canonical `us-east` / `eu-west` / `sgp` set +3. `eu-west` — back-compat default for the original single-region deploy + +### Multi-region deploy — two options + +**Option A: one service, replicas per region (recommended on Railway).** Go to the service → Settings → Regions → "+ Add Region". Pick US East + EU West + Asia Southeast (Singapore). Railway sets `RAILWAY_REPLICA_REGION` on each replica and the harness self-labels correctly. Prometheus scrapes one internal DNS target (`rpc-capabilities.railway.internal:2112`) and the load-balancer cycles through replicas. + +**Option B: three separate services.** Use this when you want predictable scrape targets or per-region service ownership (e.g. for cost attribution). -- **Single geo** — the bench currently runs from one Railway region. Latency rankings can reorder under different network paths; for full credibility the published numbers should say which geo they came from. Multi-region replication is a v2 lift (same pattern as `aggregator-head-lag`). +| Railway service | `REGION` env | Railway region | Internal DNS | +|---|---|---|---| +| `rpc-capabilities-us` | `us-east` | us-east4 | `rpc-capabilities-us.railway.internal:2112` | +| `rpc-capabilities-eu` | `eu-west` | europe-west4 | `rpc-capabilities-eu.railway.internal:2112` | +| `rpc-capabilities-sgp` | `sgp` | asia-southeast1 | `rpc-capabilities-sgp.railway.internal:2112` | + +Prometheus scrape config for Option B: + +```yaml +- job_name: 'rpc-capabilities' + static_configs: + - targets: + - 'rpc-capabilities-us.railway.internal:2112' + - 'rpc-capabilities-eu.railway.internal:2112' + - 'rpc-capabilities-sgp.railway.internal:2112' + metrics_path: /metrics +``` + +Either way Prometheus records three series per `(provider, chain)` pair — one per region. The bench's PromQL queries pool across regions for the headline number and slice by region for the per-region chart. + +## Known limits - **Shard variance** — `1rpc` and `flashbots` appear to load-balance across a mixed pool where some shards are pruned. The bench observes the variance honestly but reports per-cycle, so a single hourly cycle can land on either the archive or pruned shard. A v2 improvement would do a 3-probe quorum per (provider × depth). - **Stale-state classification** — needs ≥2 working providers per chain to compute the cross-provider tip. If both Ethereum providers fail simultaneously, `stale` cannot be detected; the harness falls back to whatever tip it last observed. - **No rate-limit signals** — none of the probed endpoints return `x-ratelimit-*` headers, so the bench cannot proactively show quotas. KyberSwap-style explicit limits would be a nice future addition. diff --git a/harnesses/rpc-capabilities/cmd/script/archive.go b/harnesses/rpc-capabilities/cmd/script/archive.go index 826df89d..e4069813 100644 --- a/harnesses/rpc-capabilities/cmd/script/archive.go +++ b/harnesses/rpc-capabilities/cmd/script/archive.go @@ -64,7 +64,7 @@ func archiveOne(ctx context.Context, c Chain, p Provider) { // Mark every depth as unsupported so a flap doesn't // leave a stale `1` in the time series. for _, d := range depthBuckets { - rpcArchiveDepth.WithLabelValues(p.Slug, c.Slug, fmt.Sprintf("%d", d)).Set(0) + rpcArchiveDepth.WithLabelValues(p.Slug, c.Slug, currentRegion, fmt.Sprintf("%d", d)).Set(0) } return } @@ -81,7 +81,7 @@ func archiveOne(ctx context.Context, c Chain, p Provider) { if ok { val = 1.0 } - rpcArchiveDepth.WithLabelValues(p.Slug, c.Slug, fmt.Sprintf("%d", d)).Set(val) + rpcArchiveDepth.WithLabelValues(p.Slug, c.Slug, currentRegion, fmt.Sprintf("%d", d)).Set(val) fmt.Printf("[archive/%s/%s] depth=%d head=%d ok=%v\n", c.Slug, p.Slug, d, head, ok) select { case <-ctx.Done(): diff --git a/harnesses/rpc-capabilities/cmd/script/main.go b/harnesses/rpc-capabilities/cmd/script/main.go index c47c3836..25088458 100644 --- a/harnesses/rpc-capabilities/cmd/script/main.go +++ b/harnesses/rpc-capabilities/cmd/script/main.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "os/signal" + "strings" "syscall" ) @@ -15,9 +16,53 @@ import ( // listener away from the address Prometheus expects. Same fix as // l2-block-time (see mobula-api commit 833026a719). +// currentRegion is the value stamped onto every emitted Prometheus +// label as `region=`. Set once at startup. Resolution order: +// 1. $REGION (explicit override — set this when running 3 separate +// Railway services instead of replicas of one) +// 2. $RAILWAY_REPLICA_REGION (Railway sets this per replica when +// a single service is scaled across regions via "Add Region") +// 3. "eu-west" (back-compat default for the original single-region +// deploy that pre-dates region labeling) +var currentRegion = loadRegion() + +func loadRegion() string { + if r := strings.TrimSpace(os.Getenv("REGION")); r != "" { + return r + } + if r := normalizeRailwayRegion(os.Getenv("RAILWAY_REPLICA_REGION")); r != "" { + return r + } + return "eu-west" +} + +// Railway exposes replica region as raw GCP-style slugs like +// "us-east4-eqdc4a" / "europe-west4-drams5" / "asia-southeast1-eqsg3a". +// We map them down to the canonical 3-region set the bench publishes +// (us-east / eu-west / sgp) so the Prom label space stays small. +// Unknown values pass through so a new Railway region surfaces in the +// metric instead of silently bucketing as "eu-west". +func normalizeRailwayRegion(raw string) string { + raw = strings.ToLower(strings.TrimSpace(raw)) + if raw == "" { + return "" + } + switch { + case strings.HasPrefix(raw, "us-"), strings.HasPrefix(raw, "northamerica"): + return "us-east" + case strings.HasPrefix(raw, "europe"), strings.HasPrefix(raw, "eu-"): + return "eu-west" + case strings.HasPrefix(raw, "asia"), strings.HasPrefix(raw, "ap-"): + return "sgp" + default: + return raw + } +} + func main() { fmt.Println("=== RPC Capabilities Harness ===") fmt.Println("OpenChainBench - public RPC latency, reliability, and archive depth.") + fmt.Printf("Region: %s (set via $REGION env)\n", currentRegion) fmt.Println() for _, c := range chains() { diff --git a/harnesses/rpc-capabilities/cmd/script/metrics.go b/harnesses/rpc-capabilities/cmd/script/metrics.go index 8ec2470e..899714fe 100644 --- a/harnesses/rpc-capabilities/cmd/script/metrics.go +++ b/harnesses/rpc-capabilities/cmd/script/metrics.go @@ -22,7 +22,7 @@ var ( Name: "rpc_latency_milliseconds", Help: "Latest observed HTTP round-trip in milliseconds for `eth_blockNumber` against a public RPC endpoint.", }, - []string{"provider", "chain"}, + []string{"provider", "chain", "region"}, ) rpcLatencyHist = promauto.NewHistogramVec( @@ -31,7 +31,7 @@ var ( Help: "Histogram of public RPC `eth_blockNumber` latencies — drives the p50/p90/p99 leaderboard via `histogram_quantile` / `quantile_over_time`.", Buckets: []float64{50, 100, 150, 200, 300, 500, 750, 1000, 1500, 2000, 3000, 5000, 10000}, }, - []string{"provider", "chain"}, + []string{"provider", "chain", "region"}, ) rpcCallTotal = promauto.NewCounterVec( @@ -39,7 +39,7 @@ var ( Name: "rpc_call_total", Help: "Number of RPC calls broken down by result classification: ok, http_err (status != 200 or transport failure), jsonrpc_err (HTTP 200 with `error` field — Ankr/Cloudflare-eth trap), stale (block more than 20 behind cross-provider tip), timeout.", }, - []string{"provider", "chain", "result"}, + []string{"provider", "chain", "region", "result"}, ) rpcHealth = promauto.NewGaugeVec( @@ -47,7 +47,7 @@ var ( Name: "rpc_health", Help: "1 when the most recent latency probe returned a fresh, valid block, 0 otherwise.", }, - []string{"provider", "chain"}, + []string{"provider", "chain", "region"}, ) rpcArchiveDepth = promauto.NewGaugeVec( @@ -55,7 +55,7 @@ var ( Name: "rpc_archive_depth_supported", Help: "1 if `eth_getBalance` at (head - depth) returns a non-pruned response, 0 otherwise. depth label is in blocks. The set {300, 7200, 216000, 1296000, 5000000} covers Geth's default pruned cap up through genesis-era full archive.", }, - []string{"provider", "chain", "depth"}, + []string{"provider", "chain", "region", "depth"}, ) ) diff --git a/harnesses/rpc-capabilities/cmd/script/probe.go b/harnesses/rpc-capabilities/cmd/script/probe.go index 72115353..b6324445 100644 --- a/harnesses/rpc-capabilities/cmd/script/probe.go +++ b/harnesses/rpc-capabilities/cmd/script/probe.go @@ -136,8 +136,8 @@ 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).Set(latency) - rpcLatencyHist.WithLabelValues(p.Slug, c.Slug).Observe(latency) + 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) @@ -146,12 +146,12 @@ func probeOne(ctx context.Context, c Chain, p Provider) { result = "stale" } } - rpcCallTotal.WithLabelValues(p.Slug, c.Slug, result).Inc() + rpcCallTotal.WithLabelValues(p.Slug, c.Slug, currentRegion, result).Inc() if result == "ok" { - rpcHealth.WithLabelValues(p.Slug, c.Slug).Set(1) + 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 { - rpcHealth.WithLabelValues(p.Slug, c.Slug).Set(0) + 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) } } diff --git a/harnesses/solana-quote-latency/.env.example b/harnesses/solana-quote-latency/.env.example new file mode 100644 index 00000000..e0efb9c9 --- /dev/null +++ b/harnesses/solana-quote-latency/.env.example @@ -0,0 +1,19 @@ +# Region this replica self-labels into every emitted metric. +# One Railway service per region, each with its own MONITOR_REGION value. +# us-east | eu-west | sgp | unknown (default) +MONITOR_REGION=local + +# Mobula API key (required to probe Mobula). +# Sent as raw `Authorization: ` header — NO Bearer prefix. +MOBULA_API_KEY= + +# Jupiter API key (optional). Public lite endpoint works without it. +JUPITER_API_KEY= + +# DFlow API key (reserved). The DFlow adapter is currently a stub +# pending partnership API access. +DFLOW_API_KEY= + +# Optional shared token gating GET /logs?tail=N. +# When unset, /logs returns 404. +LOGS_TOKEN= diff --git a/harnesses/solana-quote-latency/.gitignore b/harnesses/solana-quote-latency/.gitignore new file mode 100644 index 00000000..089d940c --- /dev/null +++ b/harnesses/solana-quote-latency/.gitignore @@ -0,0 +1 @@ +/monitor diff --git a/harnesses/solana-quote-latency/Dockerfile b/harnesses/solana-quote-latency/Dockerfile new file mode 100644 index 00000000..8868166b --- /dev/null +++ b/harnesses/solana-quote-latency/Dockerfile @@ -0,0 +1,28 @@ +# Build stage +FROM golang:1.24-alpine AS builder + +WORKDIR /app + +RUN apk add --no-cache git + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux go build -o /app/monitor ./cmd/monitor + +# Runtime stage +FROM debian:bookworm-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /app/monitor /app/monitor + +EXPOSE 2112 + +CMD ["/app/monitor"] diff --git a/harnesses/solana-quote-latency/README.md b/harnesses/solana-quote-latency/README.md new file mode 100644 index 00000000..f278b564 --- /dev/null +++ b/harnesses/solana-quote-latency/README.md @@ -0,0 +1,104 @@ +# solana-quote-latency harness + +Source for OpenChainBench bench [`solana-dex-quote-latency`](https://openchainbench.com/benchmarks/solana-dex-quote-latency). + +Every 60 s the harness picks a **currently-trending Solana token** from a live Pulse V2 WebSocket feed and asks each of Jupiter, Mobula, OpenOcean and Raydium for a `100 USDC → tokenOut` quote. Rotating against tokens that are actually trending right now defeats every per-pair edge cache and forces each provider to actually search a route — the recorded number reflects routing-search cost, not a CDN hit. + +Exposes Prometheus metrics on `:2112/metrics` (OCB Railway convention). + +## How it works + +### Rotation source + +A persistent WebSocket goroutine subscribes to `wss://pulse-v2-api.mobula.io` (`model: "default"`, `assetMode: true`, `chainId: ["solana:solana"]`, view `new`). The server auto-pushes all three views — `new`, `bonding`, `bonded` — over the same connection. The subscriber filters incoming events to `viewName == "bonded"` (post-bonding-curve graduates from Pump.fun, Meteora DBC, Raydium LaunchLab) and maintains a sliding 30-minute pool deduped by mint. On the Solana bonded view this stabilises at ~50-300 active mints. A REST snapshot of `api.mobula.io/api/1/market/query` (Solana volume ranks 6-55) refreshes every 10 minutes as a passive fallback so the bench survives a Pulse outage. In steady state every tick is sourced from the WS feed. + +> Mobula caps each API key at 3 tracked views across all open subscriptions. Each Railway replica (us-east, eu-west, sgp) opens 1 view = 3 total — right at the cap. Do not add another Pulse-using harness on the same key. + +### Per tick + +One tick = one randomly picked token (Pulse first, REST fallback) × four parallel probes. Each provider adapter: + +1. Builds the canonical request: `inputMint=USDC, outputMint=, amount=100 USDC, slippage=1%`. +2. Reuses a per-provider `http.Client` configured for keep-alive (30 s `KeepAlive`, 90 s `IdleConnTimeout`, HTTP/2 attempt). After the first tick the TCP + TLS connection is reused, so the wallclock around `client.Do(req)` measures steady-state RTT (request handling + origin work + response), not the cold handshake. +3. Measures wallclock from `client.Do` dispatch to the first byte of a response body containing a usable out-amount (`outAmount` for Jupiter, `data.amountOutTokens` for Mobula, `data.outAmount` for OpenOcean, `data.outputAmount` for Raydium). +4. Records the sample on `solana_quote_latency_ms{provider, region}` **only when** the response is a real quote. Throttle / auth / no-route / network errors land on their own counters and are excluded from the histogram. + +Region is set via `MONITOR_REGION` on each Railway replica. + +## Failure classification + +Each tick records exactly one counter for a non-success outcome: + +| Counter | Trigger | +|---|---| +| `solana_quote_throttled_total` | HTTP 429 | +| `solana_quote_auth_error_total` | HTTP 401/403 | +| `solana_quote_no_route_total` | Provider returned a recognisable "this token has no path on me" signal (see below) | +| `solana_quote_other_error_total{error_type}` | Anything else (network/timeout/parse/validation/non-2xx) | + +`no_route` is its own bucket because every provider is allowed to honestly say "I can't route this token". Counting that as a latency outlier would punish providers that fail fast and reward providers that hallucinate a number. Verified no-route signals: + +- **Jupiter**: HTTP 400 with `errorCode: NO_ROUTES_FOUND` or `errorCode: TOKEN_NOT_TRADABLE`. +- **Mobula**: HTTP 4xx with `error: "No route found"` or `error: "Token not found: solana:solana:"`. +- **Raydium**: HTTP 200 + `success: false` + `msg: INSUFFICIENT_LIQUIDITY` or `msg: ROUTE_NOT_FOUND`. Raydium's compute API is single-venue (Raydium AMM v4/CPMM/CLMM only) and does not multi-hop, so it returns no-route on roughly 30-50% of long-tail tokens — that's a real coverage signal, not a bug. +- **OpenOcean**: HTTP 200 wrapper `code: 200`, data `code: 0`, but `outAmount == "0"` or `dexId < 0` or `path == null` or `price_impact == "-100%"`. OpenOcean has no string error for no-route, so we read the payload. + +## Metrics + +``` +solana_quote_latency_ms{provider, region} histogram — buckets 10, 25, 50, 100, 200, 500, 1000, 2000, 5000 ms (only on success) +solana_quote_success{provider, region} gauge 0|1 — last cycle outcome +solana_quote_throttled_total{provider, region} counter — HTTP 429 +solana_quote_auth_error_total{provider, region} counter — HTTP 401/403 +solana_quote_no_route_total{provider, region} counter — liquidity-gap failures +solana_quote_other_error_total{provider, region, error_type} counter — everything else +``` + +## Run locally + +```bash +cd cmd/monitor +MONITOR_REGION=local MOBULA_API_KEY=... go run . +``` + +Or Docker: + +```bash +docker build -t solana-quote-latency . +docker run -p 2112:2112 \ + -e MONITOR_REGION=local \ + -e MOBULA_API_KEY=... \ + solana-quote-latency +curl localhost:2112/metrics | grep solana_quote_latency +``` + +## Environment + +| Var | Default | Notes | +|---|---|---| +| `MONITOR_REGION` | `unknown` | `us-east` \| `eu-west` \| `sgp`. Self-labelled into every emitted metric. | +| `MOBULA_API_KEY` | (required) | Required to probe Mobula AND to fetch the trending rotation list. Sent as raw `Authorization: ` header (no Bearer prefix). | +| `JUPITER_API_KEY` | (unset) | Optional. The lite endpoint works without a key. | +| `DFLOW_API_KEY` | (unset) | Reserved. DFlow adapter is a stub pending partnership API access. | +| `LOGS_TOKEN` | (unset) | Optional, gates `GET /logs?tail=N` (404 when unset). | + +## Endpoints + +| Path | Description | +|---|---| +| `GET /metrics` | Prometheus scrape target | +| `GET /health` | Returns `200 OK` | +| `GET /logs?tail=N` | Last 5000 lines of stdout (ring buffer). Requires `X-Logs-Token: $LOGS_TOKEN` | + +## Design notes + +- **Token rotation, not a canonical pair.** Anchoring the bench to SOL → USDC (or any single popular pair) meant we were comparing edge caches, not routing. The Pulse-fed rotation against tokens that are actually trending right now forces every provider to actually search a path. +- **Warm-path measurement.** Each provider keeps one `http.Client` for the life of the process and reuses TCP + TLS across ticks. We measure steady-state RTT, not the first-call cold-start. +- **10 s per-request timeout.** Slow responses are recorded as `other_error{error_type="timeout"}`, not as 10 s latency samples. +- **60 s tick.** ≈ 1,440 samples per provider per region per day, with each tick targeting a different token from the ~40-token rotation list. +- **Histogram-only buckets.** No quantile is computed inside the harness; the bench YAML uses `histogram_quantile()` on Prometheus's side over a 24-hour window. This keeps the percentiles correct across restarts and replicas. +- **Region is hosted, not virtual.** Three Railway services in three regions each run this binary with their own `MONITOR_REGION` env. + +## Reproducibility + +Public quote endpoints. A Mobula API key is free (`https://mobula.io/api`). Anyone can clone, run, and reproduce the numbers on the bench page. diff --git a/harnesses/solana-quote-latency/cmd/monitor/config.go b/harnesses/solana-quote-latency/cmd/monitor/config.go new file mode 100644 index 00000000..848bbe8b --- /dev/null +++ b/harnesses/solana-quote-latency/cmd/monitor/config.go @@ -0,0 +1,82 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "strings" +) + +type Config struct { + MobulaAPIKey string + DFlowAPIKey string + JupiterAPIKey string + MonitorRegion string + LogsToken string +} + +func loadEnv() (*Config, error) { + cfg := &Config{ + MobulaAPIKey: strings.TrimSpace(os.Getenv("MOBULA_API_KEY")), + DFlowAPIKey: strings.TrimSpace(os.Getenv("DFLOW_API_KEY")), + JupiterAPIKey: strings.TrimSpace(os.Getenv("JUPITER_API_KEY")), + MonitorRegion: strings.TrimSpace(os.Getenv("MONITOR_REGION")), + LogsToken: strings.TrimSpace(os.Getenv("LOGS_TOKEN")), + } + + if cfg.MonitorRegion == "" { + cfg.MonitorRegion = "unknown" + } + + // If any env var is already populated assume we're in production / Railway + // and skip the .env file lookup. + if cfg.MobulaAPIKey != "" || cfg.DFlowAPIKey != "" || cfg.JupiterAPIKey != "" || cfg.LogsToken != "" { + return cfg, nil + } + + file, err := os.Open(".env") + if err != nil { + return cfg, 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 "MOBULA_API_KEY": + if cfg.MobulaAPIKey == "" { + cfg.MobulaAPIKey = value + } + case "DFLOW_API_KEY": + if cfg.DFlowAPIKey == "" { + cfg.DFlowAPIKey = value + } + case "JUPITER_API_KEY": + if cfg.JupiterAPIKey == "" { + cfg.JupiterAPIKey = value + } + case "MONITOR_REGION": + if cfg.MonitorRegion == "" || cfg.MonitorRegion == "unknown" { + cfg.MonitorRegion = value + } + case "LOGS_TOKEN": + if cfg.LogsToken == "" { + cfg.LogsToken = value + } + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("error reading .env file: %w", err) + } + return cfg, nil +} diff --git a/harnesses/solana-quote-latency/cmd/monitor/dflow.go b/harnesses/solana-quote-latency/cmd/monitor/dflow.go new file mode 100644 index 00000000..03f2d4b2 --- /dev/null +++ b/harnesses/solana-quote-latency/cmd/monitor/dflow.go @@ -0,0 +1,27 @@ +package main + +import ( + "context" + "fmt" +) + +// DFlowProvider — STUB. +// TODO: implement based on agent-1's curl validation. DFlow's quote endpoint +// and auth shape must be captured first, then ported following Jupiter as a +// template. +type DFlowProvider struct { + region string + apiKey string +} + +func NewDFlowProvider(region, apiKey string) *DFlowProvider { + return &DFlowProvider{region: region, apiKey: apiKey} +} + +func (p *DFlowProvider) Slug() string { return "dflow" } + +func (p *DFlowProvider) Probe(ctx context.Context, tokenOut TrendingToken) (int64, bool, error) { + // TODO: implement based on agent-1's curl validation + RecordOtherError(p.Slug(), p.region, "not_implemented") + return 0, false, fmt.Errorf("dflow provider not implemented") +} diff --git a/harnesses/solana-quote-latency/cmd/monitor/http_client.go b/harnesses/solana-quote-latency/cmd/monitor/http_client.go new file mode 100644 index 00000000..d3f3349f --- /dev/null +++ b/harnesses/solana-quote-latency/cmd/monitor/http_client.go @@ -0,0 +1,35 @@ +package main + +import ( + "net" + "net/http" + "time" +) + +// newWarmHTTPClient returns an http.Client wired for connection reuse. +// +// Bench-029 measures the steady-state RTT a long-running backend integration +// sees, not the first-request cold-start penalty. So every provider adapter +// constructs ONE shared client at boot, reuses TCP + TLS across ticks via +// keepalive, and the only thing the wallclock around `client.Do(req)` +// captures is request handling + origin work + response. +// +// Connection pool sizing assumes the harness probes one endpoint per +// provider on a 60s tick — a couple of idle connections per host is plenty. +func newWarmHTTPClient() *http.Client { + return &http.Client{ + Timeout: 10 * time.Second, + Transport: &http.Transport{ + DialContext: (&net.Dialer{ + Timeout: 10 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + MaxIdleConns: 5, + MaxIdleConnsPerHost: 2, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + ForceAttemptHTTP2: true, + }, + } +} diff --git a/harnesses/solana-quote-latency/cmd/monitor/jupiter.go b/harnesses/solana-quote-latency/cmd/monitor/jupiter.go new file mode 100644 index 00000000..bd10ec55 --- /dev/null +++ b/harnesses/solana-quote-latency/cmd/monitor/jupiter.go @@ -0,0 +1,154 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// JupiterProvider quotes USDC -> tokenOut on Jupiter's public lite quote +// endpoint for a fixed 100 USDC notional. Rotating tokenOut every tick from a +// long-tail volume window defeats Jupiter's edge cache for popular pairs (SOL, +// USDT, CBBTC) so the bench measures the routing search rather than a CDN hit. +type JupiterProvider struct { + region string + apiKey string + client *http.Client +} + +func NewJupiterProvider(region, apiKey string) *JupiterProvider { + return &JupiterProvider{region: region, apiKey: apiKey, client: newWarmHTTPClient()} +} + +func (p *JupiterProvider) Slug() string { return "jupiter" } + +const ( + jupiterQuoteEndpoint = "https://lite-api.jup.ag/swap/v1/quote" + solMint = "So11111111111111111111111111111111111111112" + usdcMint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" + canonicalUsdcRaw = "100000000" // 100 USDC, 6 decimals + canonicalUsdcWhole = "100" + canonicalSlippageBps = "100" // 1% - long-tail tokens need wider tolerance + canonicalSlippagePct = "1" // same 1% expressed as percent for APIs that take percent +) + +type jupiterQuoteResp struct { + OutAmount string `json:"outAmount"` + ErrorCode string `json:"errorCode"` + ErrorMessage string `json:"error"` +} + +func (p *JupiterProvider) Probe(ctx context.Context, tokenOut TrendingToken) (int64, bool, error) { + q := url.Values{} + q.Set("inputMint", usdcMint) + q.Set("outputMint", tokenOut.Mint) + q.Set("amount", canonicalUsdcRaw) + q.Set("slippageBps", canonicalSlippageBps) + + fullURL := jupiterQuoteEndpoint + "?" + q.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil) + if err != nil { + RecordOtherError(p.Slug(), p.region, "request_build") + return 0, false, fmt.Errorf("build request: %w", err) + } + req.Header.Set("Accept", "application/json") + if p.apiKey != "" { + req.Header.Set("X-API-Key", p.apiKey) + } + + start := time.Now() + resp, err := p.client.Do(req) + if err != nil { + errType := "network" + if errors.Is(err, context.DeadlineExceeded) { + errType = "timeout" + } + RecordOtherError(p.Slug(), p.region, errType) + return 0, false, fmt.Errorf("jupiter http: %w", err) + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusTooManyRequests: + _, _ = io.Copy(io.Discard, resp.Body) + RecordThrottled(p.Slug(), p.region) + return 0, false, fmt.Errorf("jupiter http 429") + case http.StatusUnauthorized, http.StatusForbidden: + _, _ = io.Copy(io.Discard, resp.Body) + RecordAuthError(p.Slug(), p.region) + return 0, false, fmt.Errorf("jupiter http %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + RecordOtherError(p.Slug(), p.region, "read_body") + return 0, false, fmt.Errorf("read body: %w", err) + } + + // Jupiter returns HTTP 400 with errorCode "COULD_NOT_FIND_ANY_ROUTE" for + // tokens it can't route. That's a liquidity gap, not a server error, so it + // goes on the no-route counter and is excluded from the latency histogram. + if resp.StatusCode == http.StatusBadRequest { + var parsed jupiterQuoteResp + _ = json.Unmarshal(body, &parsed) + if isNoRouteSignal(parsed.ErrorCode, parsed.ErrorMessage) { + RecordNoRoute(p.Slug(), p.region) + return 0, false, fmt.Errorf("jupiter no route for %s", tokenOut.Symbol) + } + RecordOtherError(p.Slug(), p.region, "http_400") + return 0, false, fmt.Errorf("jupiter http 400: %s", parsed.ErrorMessage) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + RecordOtherError(p.Slug(), p.region, fmt.Sprintf("http_%d", resp.StatusCode)) + return 0, false, fmt.Errorf("jupiter http %d", resp.StatusCode) + } + + var parsed jupiterQuoteResp + if err := json.Unmarshal(body, &parsed); err != nil { + RecordOtherError(p.Slug(), p.region, "parse") + return 0, false, fmt.Errorf("parse: %w", err) + } + if parsed.OutAmount == "" { + RecordNoRoute(p.Slug(), p.region) + return 0, false, fmt.Errorf("jupiter empty outAmount for %s", tokenOut.Symbol) + } + + latencyMs := time.Since(start).Milliseconds() + return latencyMs, true, nil +} + +// isNoRouteSignal classifies a provider error as "the token doesn't have a +// quotable path on this provider" (vs server error, validation error, auth). +// +// Concrete shapes verified against each provider's actual responses (see +// agent deep-test in PR description): +// - Jupiter HTTP 400 → errorCode NO_ROUTES_FOUND | TOKEN_NOT_TRADABLE +// - Mobula HTTP 200 → error "No route found" OR "Token not found: ..." +// - OpenOcean HTTP 200 wrapper → handled separately by inspecting payload +// (outAmount=="0" || dexId<0 || path==null). No string signal. +// - Raydium HTTP 200 success:false → msg INSUFFICIENT_LIQUIDITY | ROUTE_NOT_FOUND +func isNoRouteSignal(code, msg string) bool { + upper := strings.ToUpper(code) + " " + strings.ToUpper(msg) + low := strings.ToLower(msg) + if strings.Contains(upper, "COULD_NOT_FIND_ANY_ROUTE") || + strings.Contains(upper, "INSUFFICIENT_LIQUIDITY") || + strings.Contains(upper, "NO_ROUTE") || // matches NO_ROUTE, NO_ROUTES, NO_ROUTES_FOUND + strings.Contains(upper, "ROUTE_NOT_FOUND") || + strings.Contains(upper, "TOKEN_NOT_TRADABLE") { + return true + } + return strings.Contains(low, "no route") || + strings.Contains(low, "no routes") || + strings.Contains(low, "could not find") || + strings.Contains(low, "insufficient liquidity") || + strings.Contains(low, "route not found") || + strings.Contains(low, "not tradable") || + strings.Contains(low, "token not found") +} diff --git a/harnesses/solana-quote-latency/cmd/monitor/log_buffer.go b/harnesses/solana-quote-latency/cmd/monitor/log_buffer.go new file mode 100644 index 00000000..f4a66bb8 --- /dev/null +++ b/harnesses/solana-quote-latency/cmd/monitor/log_buffer.go @@ -0,0 +1,113 @@ +package main + +import ( + "bufio" + "fmt" + "io" + "net/http" + "os" + "strconv" + "sync" + "time" +) + +// logBuffer keeps the last N log lines in memory for debug fetching via /logs. +// Captures BOTH log.* and fmt.Print* output (stdout is dup'd via a pipe). +// +// Implementation: fixed-size circular buffer. `head` points at the next slot +// to write; once `filled` is true every overwrite is O(1) instead of the O(n) +// slice-shift the prior version used. +type logBuffer struct { + mu sync.Mutex + lines []string + max int + head int // next write index + filled bool // wrapped at least once +} + +const logBufferMax = 5000 + +var globalLogBuffer = &logBuffer{lines: make([]string, logBufferMax), max: logBufferMax} + +func (b *logBuffer) push(line string) { + entry := time.Now().UTC().Format("2006-01-02T15:04:05.000Z") + " " + line + b.mu.Lock() + b.lines[b.head] = entry + b.head++ + if b.head >= b.max { + b.head = 0 + b.filled = true + } + b.mu.Unlock() +} + +func (b *logBuffer) Snapshot(tail int) []string { + b.mu.Lock() + defer b.mu.Unlock() + size := b.head + if b.filled { + size = b.max + } + if tail <= 0 || tail >= size { + tail = size + } + out := make([]string, 0, tail) + // Walk back `tail` slots from head-1 (modular). + for i := 0; i < tail; i++ { + idx := (b.head - tail + i + b.max) % b.max + out = append(out, b.lines[idx]) + } + return out +} + +// installLogCapture replaces os.Stdout with the write-end of a pipe, then +// spawns a goroutine that fan-outs every line to the real stdout AND the +// in-memory ring buffer. Catches fmt.Println/Printf as well as log.Printf. +// Call exactly once, very early in main(). +func installLogCapture() { + originalStdout := os.Stdout + r, w, err := os.Pipe() + if err != nil { + fmt.Fprintf(originalStdout, "[log_buffer] failed to create pipe: %v (logs endpoint will be empty)\n", err) + return + } + os.Stdout = 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) + globalLogBuffer.push(line) + } + _, _ = io.Copy(originalStdout, r) + }() +} + +// setupLogsEndpoint exposes GET /logs?tail=N (default 500, max logBufferMax). +// Fail-secure: when LOGS_TOKEN env var is not set, returns 404. +func setupLogsEndpoint(mux *http.ServeMux) { + expectedToken := os.Getenv("LOGS_TOKEN") + mux.HandleFunc("/logs", func(w http.ResponseWriter, r *http.Request) { + if expectedToken == "" { + http.NotFound(w, r) + return + } + if r.Header.Get("X-Logs-Token") != expectedToken { + 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 globalLogBuffer.Snapshot(tail) { + fmt.Fprintln(w, l) + } + }) +} diff --git a/harnesses/solana-quote-latency/cmd/monitor/main.go b/harnesses/solana-quote-latency/cmd/monitor/main.go new file mode 100644 index 00000000..b4d742ac --- /dev/null +++ b/harnesses/solana-quote-latency/cmd/monitor/main.go @@ -0,0 +1,59 @@ +package main + +import ( + "fmt" + "os" + "os/signal" + "sync" + "syscall" +) + +func main() { + installLogCapture() // must be first — captures all subsequent stdout into ring buffer for /logs + fmt.Println("=== Solana Quote Latency Monitor (v2 — rotating long-tail tokens) ===") + fmt.Println("Measuring USDC -> quote latency every 60s") + fmt.Println("Token rotation defeats per-pair CDN caches; we measure routing search.") + fmt.Println("Press Ctrl+C to stop") + fmt.Println() + + cfg, err := loadEnv() + if err != nil { + fmt.Printf("Error: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Region: %s\n", cfg.MonitorRegion) + fmt.Printf("Mobula key set: %v\n", cfg.MobulaAPIKey != "") + fmt.Printf("DFlow key set: %v\n", cfg.DFlowAPIKey != "") + fmt.Printf("Jupiter key set: %v (public lite endpoint works without it)\n", cfg.JupiterAPIKey != "") + fmt.Println("Metrics on :2112/metrics, logs on :2112/logs (LOGS_TOKEN gated)") + fmt.Println() + + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + + var wg sync.WaitGroup + stopChan := 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() + runScheduler(cfg, stopChan) + }() + + <-sigChan + fmt.Println("\n\nShutting down...") + close(stopChan) + + wg.Wait() + fmt.Println("All monitors stopped") +} diff --git a/harnesses/solana-quote-latency/cmd/monitor/metrics.go b/harnesses/solana-quote-latency/cmd/monitor/metrics.go new file mode 100644 index 00000000..23b0602b --- /dev/null +++ b/harnesses/solana-quote-latency/cmd/monitor/metrics.go @@ -0,0 +1,115 @@ +package main + +import ( + "net/http" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +var ( + quoteLatencyMs *prometheus.HistogramVec + quoteSuccess *prometheus.GaugeVec + quoteThrottled *prometheus.CounterVec + quoteAuthError *prometheus.CounterVec + quoteNoRoute *prometheus.CounterVec + quoteOtherError *prometheus.CounterVec +) + +func init() { + quoteLatencyMs = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "solana_quote_latency_ms", + Help: "Solana DEX quote API latency in milliseconds (successful quotes only)", + Buckets: []float64{10, 25, 50, 100, 200, 500, 1000, 2000, 5000}, + }, + []string{"provider", "region"}, + ) + prometheus.MustRegister(quoteLatencyMs) + + quoteSuccess = prometheus.NewGaugeVec( + prometheus.GaugeOpts{ + Name: "solana_quote_success", + Help: "Last probe outcome per provider (1 = success, 0 = failure)", + }, + []string{"provider", "region"}, + ) + prometheus.MustRegister(quoteSuccess) + + quoteThrottled = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "solana_quote_throttled_total", + Help: "Total HTTP 429 responses per provider", + }, + []string{"provider", "region"}, + ) + prometheus.MustRegister(quoteThrottled) + + quoteAuthError = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "solana_quote_auth_error_total", + Help: "Total HTTP 401/403 responses per provider", + }, + []string{"provider", "region"}, + ) + prometheus.MustRegister(quoteAuthError) + + quoteNoRoute = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "solana_quote_no_route_total", + Help: "Total times a provider returned no route for the picked tokenOut (long-tail liquidity gap, not a server error)", + }, + []string{"provider", "region"}, + ) + prometheus.MustRegister(quoteNoRoute) + + quoteOtherError = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "solana_quote_other_error_total", + Help: "Total non-auth, non-throttle, non-no-route errors per provider (network, timeout, parse, non-2xx)", + }, + []string{"provider", "region", "error_type"}, + ) + prometheus.MustRegister(quoteOtherError) +} + +// RecordLatency observes a successful quote latency. +// Caller MUST NOT call this on auth / throttle / parse failures. +func RecordLatency(provider, region string, latencyMs int64) { + quoteLatencyMs.WithLabelValues(provider, region).Observe(float64(latencyMs)) +} + +func RecordSuccess(provider, region string, ok bool) { + v := 0.0 + if ok { + v = 1.0 + } + quoteSuccess.WithLabelValues(provider, region).Set(v) +} + +func RecordThrottled(provider, region string) { + quoteThrottled.WithLabelValues(provider, region).Inc() +} + +func RecordAuthError(provider, region string) { + quoteAuthError.WithLabelValues(provider, region).Inc() +} + +func RecordNoRoute(provider, region string) { + quoteNoRoute.WithLabelValues(provider, region).Inc() +} + +func RecordOtherError(provider, region, errorType string) { + quoteOtherError.WithLabelValues(provider, region, errorType).Inc() +} + +func StartMetricsServer(addr string) error { + mux := http.NewServeMux() + mux.Handle("/metrics", promhttp.Handler()) + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) + setupLogsEndpoint(mux) + return http.ListenAndServe(addr, mux) +} diff --git a/harnesses/solana-quote-latency/cmd/monitor/mobula.go b/harnesses/solana-quote-latency/cmd/monitor/mobula.go new file mode 100644 index 00000000..70812d20 --- /dev/null +++ b/harnesses/solana-quote-latency/cmd/monitor/mobula.go @@ -0,0 +1,121 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// MobulaProvider quotes USDC -> tokenOut via Mobula's swap quoting endpoint. +// Auth header is the raw key (no Bearer). Amount is in whole tokens. +type MobulaProvider struct { + region string + apiKey string + client *http.Client +} + +func NewMobulaProvider(region, apiKey string) *MobulaProvider { + return &MobulaProvider{region: region, apiKey: apiKey, client: newWarmHTTPClient()} +} + +func (p *MobulaProvider) Slug() string { return "mobula" } + +const ( + mobulaQuoteEndpoint = "https://api.mobula.io/api/2/swap/quoting" + canonicalFromAddress = "HN7cABqLq46Es1jh92dQQisAq662SmxELLLsHHe4YWrH" +) + +type mobulaQuoteResp struct { + Data struct { + AmountOutTokens string `json:"amountOutTokens"` + } `json:"data"` + // Mobula returns 4xx with {message,error,statusCode} on validation errors, + // and {message:"No route found"} when there's no path for the requested pair. + Message string `json:"message"` + Error string `json:"error"` + StatusCode int `json:"statusCode"` +} + +func (p *MobulaProvider) Probe(ctx context.Context, tokenOut TrendingToken) (int64, bool, error) { + if p.apiKey == "" { + RecordAuthError(p.Slug(), p.region) + return 0, false, fmt.Errorf("mobula api key missing") + } + + q := url.Values{} + q.Set("chainId", "solana") + q.Set("tokenIn", usdcMint) + q.Set("tokenOut", tokenOut.Mint) + q.Set("amount", canonicalUsdcWhole) // 100 USDC, whole tokens + q.Set("walletAddress", canonicalFromAddress) + q.Set("slippage", canonicalSlippagePct) + + fullURL := mobulaQuoteEndpoint + "?" + q.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil) + if err != nil { + RecordOtherError(p.Slug(), p.region, "request_build") + return 0, false, fmt.Errorf("build request: %w", err) + } + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", p.apiKey) + + start := time.Now() + resp, err := p.client.Do(req) + if err != nil { + errType := "network" + if errors.Is(err, context.DeadlineExceeded) { + errType = "timeout" + } + RecordOtherError(p.Slug(), p.region, errType) + return 0, false, fmt.Errorf("mobula http: %w", err) + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusTooManyRequests: + _, _ = io.Copy(io.Discard, resp.Body) + RecordThrottled(p.Slug(), p.region) + return 0, false, fmt.Errorf("mobula http 429") + case http.StatusUnauthorized, http.StatusForbidden: + _, _ = io.Copy(io.Discard, resp.Body) + RecordAuthError(p.Slug(), p.region) + return 0, false, fmt.Errorf("mobula http %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + RecordOtherError(p.Slug(), p.region, "read_body") + return 0, false, fmt.Errorf("read body: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + var parsed mobulaQuoteResp + _ = json.Unmarshal(body, &parsed) + if isNoRouteSignal(parsed.Error, parsed.Message) || + strings.Contains(strings.ToLower(string(body)), "no route") { + RecordNoRoute(p.Slug(), p.region) + return 0, false, fmt.Errorf("mobula no route for %s", tokenOut.Symbol) + } + RecordOtherError(p.Slug(), p.region, fmt.Sprintf("http_%d", resp.StatusCode)) + return 0, false, fmt.Errorf("mobula http %d: %s", resp.StatusCode, parsed.Message) + } + + var parsed mobulaQuoteResp + if err := json.Unmarshal(body, &parsed); err != nil { + RecordOtherError(p.Slug(), p.region, "parse") + return 0, false, fmt.Errorf("parse: %w", err) + } + if parsed.Data.AmountOutTokens == "" { + RecordNoRoute(p.Slug(), p.region) + return 0, false, fmt.Errorf("mobula empty data.amountOutTokens for %s", tokenOut.Symbol) + } + + latencyMs := time.Since(start).Milliseconds() + return latencyMs, true, nil +} diff --git a/harnesses/solana-quote-latency/cmd/monitor/openocean.go b/harnesses/solana-quote-latency/cmd/monitor/openocean.go new file mode 100644 index 00000000..703b44d5 --- /dev/null +++ b/harnesses/solana-quote-latency/cmd/monitor/openocean.go @@ -0,0 +1,149 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// OpenOceanProvider quotes USDC -> tokenOut on OpenOcean's v4 Solana quote +// endpoint. Amount is in whole tokens. Body is double-enveloped: +// {code, data:{code, ..., outAmount}}. +type OpenOceanProvider struct { + region string + client *http.Client +} + +func NewOpenOceanProvider(region string) *OpenOceanProvider { + return &OpenOceanProvider{region: region, client: newWarmHTTPClient()} +} + +func (p *OpenOceanProvider) Slug() string { return "openocean" } + +const openOceanQuoteEndpoint = "https://open-api.openocean.finance/v4/solana/quote" + +type openOceanQuoteResp struct { + Code int `json:"code"` + Message string `json:"message"` + Data struct { + Code int `json:"code"` + Message string `json:"message"` + OutAmount string `json:"outAmount"` + DexId int `json:"dexId"` + Path any `json:"path"` // null when no route + PriceImpact string `json:"price_impact"` // "-100%" when no route + } `json:"data"` +} + +// openOceanIsNoRouteBody returns true when OpenOcean's success-looking response +// is in fact a "no route" — OpenOcean does NOT return a string error for this, +// you have to read the payload (verified via deep-test): +// - outAmount == "0" +// - dexId < 0 (negative dexId signals route-engine gave up) +// - path is null +// - price_impact == "-100%" +// Any of these is sufficient. +func openOceanIsNoRouteBody(p *openOceanQuoteResp) bool { + if p.Data.OutAmount == "0" || p.Data.OutAmount == "" { + return true + } + if p.Data.DexId < 0 { + return true + } + if p.Data.Path == nil { + return true + } + if p.Data.PriceImpact == "-100%" { + return true + } + return false +} + +func (p *OpenOceanProvider) Probe(ctx context.Context, tokenOut TrendingToken) (int64, bool, error) { + q := url.Values{} + q.Set("inTokenAddress", usdcMint) + q.Set("outTokenAddress", tokenOut.Mint) + q.Set("amount", canonicalUsdcWhole) + q.Set("gasPrice", "0.000005") + q.Set("slippage", canonicalSlippagePct) + + fullURL := openOceanQuoteEndpoint + "?" + q.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil) + if err != nil { + RecordOtherError(p.Slug(), p.region, "request_build") + return 0, false, fmt.Errorf("build request: %w", err) + } + req.Header.Set("Accept", "application/json") + + start := time.Now() + resp, err := p.client.Do(req) + if err != nil { + errType := "network" + if errors.Is(err, context.DeadlineExceeded) { + errType = "timeout" + } + RecordOtherError(p.Slug(), p.region, errType) + return 0, false, fmt.Errorf("openocean http: %w", err) + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusTooManyRequests: + _, _ = io.Copy(io.Discard, resp.Body) + RecordThrottled(p.Slug(), p.region) + return 0, false, fmt.Errorf("openocean http 429") + case http.StatusUnauthorized, http.StatusForbidden: + _, _ = io.Copy(io.Discard, resp.Body) + RecordAuthError(p.Slug(), p.region) + return 0, false, fmt.Errorf("openocean http %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + RecordOtherError(p.Slug(), p.region, "read_body") + return 0, false, fmt.Errorf("read body: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + var parsed openOceanQuoteResp + _ = json.Unmarshal(body, &parsed) + if isNoRouteSignal("", parsed.Message) || isNoRouteSignal("", parsed.Data.Message) || + strings.Contains(strings.ToLower(string(body)), "no route") { + RecordNoRoute(p.Slug(), p.region) + return 0, false, fmt.Errorf("openocean no route for %s", tokenOut.Symbol) + } + RecordOtherError(p.Slug(), p.region, fmt.Sprintf("http_%d", resp.StatusCode)) + return 0, false, fmt.Errorf("openocean http %d", resp.StatusCode) + } + + var parsed openOceanQuoteResp + if err := json.Unmarshal(body, &parsed); err != nil { + RecordOtherError(p.Slug(), p.region, "parse") + return 0, false, fmt.Errorf("parse: %w", err) + } + // OpenOcean returns HTTP 200 + wrapper code:200 + data.code:0 even on + // no-route. The signal is in the data payload (outAmount=="0", dexId<0, + // path==null, price_impact=="-100%"). Check that BEFORE we trust the + // envelope as "success". + if openOceanIsNoRouteBody(&parsed) { + RecordNoRoute(p.Slug(), p.region) + return 0, false, fmt.Errorf("openocean no route for %s (outAmount=%q dexId=%d)", tokenOut.Symbol, parsed.Data.OutAmount, parsed.Data.DexId) + } + if parsed.Code != 200 || parsed.Data.Code != 0 { + if isNoRouteSignal("", parsed.Message) || isNoRouteSignal("", parsed.Data.Message) { + RecordNoRoute(p.Slug(), p.region) + return 0, false, fmt.Errorf("openocean no route for %s (code=%d/%d)", tokenOut.Symbol, parsed.Code, parsed.Data.Code) + } + RecordOtherError(p.Slug(), p.region, fmt.Sprintf("api_code_%d_%d", parsed.Code, parsed.Data.Code)) + return 0, false, fmt.Errorf("openocean api code %d/%d: %s", parsed.Code, parsed.Data.Code, parsed.Message) + } + + latencyMs := time.Since(start).Milliseconds() + return latencyMs, true, nil +} diff --git a/harnesses/solana-quote-latency/cmd/monitor/provider.go b/harnesses/solana-quote-latency/cmd/monitor/provider.go new file mode 100644 index 00000000..a544dd5c --- /dev/null +++ b/harnesses/solana-quote-latency/cmd/monitor/provider.go @@ -0,0 +1,20 @@ +package main + +import "context" + +// Provider is the contract every quote-latency adapter satisfies. +// +// Probe quotes USDC -> tokenOut on the provider's Solana endpoint and returns: +// - latencyMs measured around the HTTP round-trip on a warm (keep-alive) +// connection. Only meaningful when ok == true. +// - ok=true ONLY when the response body parsed AND contained a usable +// out-amount field for tokenOut. +// - ok=false on any failure where the latency is NOT representative of a +// successful quote. Implementations record the failure into the right +// counter (RecordAuthError / RecordThrottled / RecordNoRoute / +// RecordOtherError) before returning so the scheduler only has to +// record latency for the happy path. +type Provider interface { + Slug() string + Probe(ctx context.Context, tokenOut TrendingToken) (latencyMs int64, ok bool, err error) +} diff --git a/harnesses/solana-quote-latency/cmd/monitor/pulse_subscriber.go b/harnesses/solana-quote-latency/cmd/monitor/pulse_subscriber.go new file mode 100644 index 00000000..67c59c38 --- /dev/null +++ b/harnesses/solana-quote-latency/cmd/monitor/pulse_subscriber.go @@ -0,0 +1,244 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +// PulseSubscriber owns a single persistent WS connection to Mobula Pulse V2 +// and pushes "currently trending" Solana mints into a sliding-window pool. +// +// Pulse V2 has no first-class "trending" view; the server's enum is locked to +// {new, bonding, bonded}. We subscribe to `new` (the only working subscription +// shape) and the server automatically emits ALL THREE views over the same +// connection. We then filter incoming `update-token` / `new-token` events to +// `viewName == "bonded"` — those are post-bonding-curve graduated tokens on +// PumpSwap/Raydium with real volume, which is the practical definition of +// "trending right now" on Solana. +// +// The bonded view emits ~10 distinct mints/min on Solana with a steady-state +// active set of 150-300 mints in any 30-min rolling window. We keep our pool +// bounded to that natural cardinality without an explicit cap. +// +// Caveat — Mobula caps each API key at 3 tracked views total across all active +// connections. If the same key is used by us-east + eu-west + sgp replicas, +// that's exactly 3 (each subscribe message uses 1 view). At the cap but not +// over. Adding any other Pulse-using harness on the same key would push it +// over and the server returns `Failed to subscribe to pulse v2 views`. +const pulseWSURL = "wss://pulse-v2-api.mobula.io" + +type pulseV2SubscribeMsg struct { + Type string `json:"type"` + Authorization string `json:"authorization"` + Payload pulseV2SubscribePayload `json:"payload"` +} + +type pulseV2SubscribePayload struct { + Model string `json:"model"` + AssetMode bool `json:"assetMode"` + ChainID []string `json:"chainId"` + Views []map[string]any `json:"views"` +} + +// pulseV2Envelope is the smallest discriminator we need to route an incoming +// message into the right handler. We unmarshal twice: once into the envelope +// to find `type`, then into the specific shape when type matches. +type pulseV2Envelope struct { + Type string `json:"type"` + Payload json.RawMessage `json:"payload"` +} + +type pulseV2TokenRecord struct { + Address string `json:"address"` + Symbol string `json:"symbol"` + ChainID string `json:"chainId"` +} + +type pulseV2TokenUpdatePayload struct { + ViewName string `json:"viewName"` + Token pulseV2TokenRecord `json:"token"` +} + +type pulseV2RemovePayload struct { + ViewName string `json:"viewName"` + TokenKey string `json:"tokenKey"` +} + +type pulseV2InitPayload struct { + Bonded struct { + Data []pulseV2TokenRecord `json:"data"` + } `json:"bonded"` +} + +// PulseSubscriber connects, reads, and writes to a token pool. Multiple users +// of the pool just call Pick on the embedded *TrendingFetcher. +type PulseSubscriber struct { + apiKey string + pool *TrendingFetcher + chainID string + + connectedAt time.Time + connected bool + connMu sync.RWMutex +} + +func NewPulseSubscriber(apiKey string, pool *TrendingFetcher) *PulseSubscriber { + return &PulseSubscriber{apiKey: apiKey, pool: pool, chainID: "solana:solana"} +} + +// IsConnected reports whether the WS session has been live within the last +// 90 s. The scheduler reads this to decide whether the pulse pool should be +// trusted as the rotation source or whether to fall back to the REST snapshot. +func (s *PulseSubscriber) IsConnected() bool { + s.connMu.RLock() + defer s.connMu.RUnlock() + if !s.connected { + return false + } + return time.Since(s.connectedAt) < 90*time.Second +} + +func (s *PulseSubscriber) markConnected() { + s.connMu.Lock() + s.connected = true + s.connectedAt = time.Now() + s.connMu.Unlock() +} + +func (s *PulseSubscriber) markDisconnected() { + s.connMu.Lock() + s.connected = false + s.connMu.Unlock() +} + +// Run blocks until ctx is cancelled. It opens one WS session, reads events +// until the connection drops, then reconnects with exponential backoff (5s +// floor, 60s ceiling). Each reconnect re-sends the subscribe payload, and the +// server replays the current snapshot via an `init` message — we use that to +// pre-fill the pool fast. +func (s *PulseSubscriber) Run(ctx context.Context) { + backoff := 5 * time.Second + const backoffCap = 60 * time.Second + for { + select { + case <-ctx.Done(): + return + default: + } + err := s.session(ctx) + s.markDisconnected() + if err != nil { + fmt.Printf("[PULSE-WS] session ended: %v — reconnect in %s\n", err, backoff) + } + select { + case <-ctx.Done(): + return + case <-time.After(backoff): + backoff *= 2 + if backoff > backoffCap { + backoff = backoffCap + } + } + // On a successful long-lived session we'd reset backoff; we approximate by + // halving on every reconnect attempt that gets past dial. + } +} + +func (s *PulseSubscriber) session(ctx context.Context) error { + dialer := *websocket.DefaultDialer + dialer.HandshakeTimeout = 15 * time.Second + hdr := http.Header{} + hdr.Set("Authorization", s.apiKey) + + conn, _, err := dialer.DialContext(ctx, pulseWSURL, hdr) + if err != nil { + return fmt.Errorf("dial: %w", err) + } + defer conn.Close() + + sub := pulseV2SubscribeMsg{ + Type: "pulse-v2", + Authorization: s.apiKey, + Payload: pulseV2SubscribePayload{ + Model: "default", + AssetMode: true, + ChainID: []string{s.chainID}, + Views: []map[string]any{ + {"name": "new", "sortBy": "created_at", "sortOrder": "desc", "limit": 50}, + }, + }, + } + if err := conn.WriteJSON(sub); err != nil { + return fmt.Errorf("subscribe write: %w", err) + } + fmt.Println("[PULSE-WS] subscribed; filtering for bonded Solana tokens") + s.markConnected() + + // Health: bump connectedAt every time we get a real message so IsConnected + // reflects liveness, not just initial dial. + for { + select { + case <-ctx.Done(): + return nil + default: + } + _ = conn.SetReadDeadline(time.Now().Add(120 * time.Second)) + _, raw, err := conn.ReadMessage() + if err != nil { + return fmt.Errorf("read: %w", err) + } + s.markConnected() + s.handleMessage(raw) + } +} + +func (s *PulseSubscriber) handleMessage(raw []byte) { + var env pulseV2Envelope + if err := json.Unmarshal(raw, &env); err != nil { + return + } + switch env.Type { + case "init": + var init pulseV2InitPayload + if err := json.Unmarshal(env.Payload, &init); err != nil { + return + } + for _, t := range init.Bonded.Data { + s.pool.Note(t.Address, t.Symbol) + } + fmt.Printf("[PULSE-WS] init: pre-loaded %d bonded tokens\n", len(init.Bonded.Data)) + case "new-token", "update-token": + var p pulseV2TokenUpdatePayload + if err := json.Unmarshal(env.Payload, &p); err != nil { + return + } + if p.ViewName != "bonded" { + return + } + if p.Token.Address == "" { + return + } + s.pool.Note(p.Token.Address, p.Token.Symbol) + case "remove-token": + var p pulseV2RemovePayload + if err := json.Unmarshal(env.Payload, &p); err != nil { + return + } + if p.ViewName != "bonded" { + return + } + // tokenKey is "solana:solana|" + parts := strings.SplitN(p.TokenKey, "|", 2) + if len(parts) != 2 { + return + } + s.pool.Forget(parts[1]) + } +} diff --git a/harnesses/solana-quote-latency/cmd/monitor/raydium.go b/harnesses/solana-quote-latency/cmd/monitor/raydium.go new file mode 100644 index 00000000..616d0d99 --- /dev/null +++ b/harnesses/solana-quote-latency/cmd/monitor/raydium.go @@ -0,0 +1,120 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// RaydiumProvider quotes USDC -> tokenOut on Raydium's single-venue Trade API. +// Raydium is NOT an aggregator; the Trade API only routes against Raydium's +// own pools, so many long-tail tokens will legitimately return "no route". +// That's intentional and surfaces the single-venue limitation in the bench. +type RaydiumProvider struct { + region string + client *http.Client +} + +func NewRaydiumProvider(region string) *RaydiumProvider { + return &RaydiumProvider{region: region, client: newWarmHTTPClient()} +} + +func (p *RaydiumProvider) Slug() string { return "raydium" } + +const raydiumQuoteEndpoint = "https://transaction-v1.raydium.io/compute/swap-base-in" + +type raydiumQuoteResp struct { + Success bool `json:"success"` + Msg string `json:"msg"` + Data struct { + OutputAmount string `json:"outputAmount"` + } `json:"data"` +} + +func (p *RaydiumProvider) Probe(ctx context.Context, tokenOut TrendingToken) (int64, bool, error) { + q := url.Values{} + q.Set("inputMint", usdcMint) + q.Set("outputMint", tokenOut.Mint) + q.Set("amount", canonicalUsdcRaw) + q.Set("slippageBps", canonicalSlippageBps) + q.Set("txVersion", "V0") + + fullURL := raydiumQuoteEndpoint + "?" + q.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil) + if err != nil { + RecordOtherError(p.Slug(), p.region, "request_build") + return 0, false, fmt.Errorf("build request: %w", err) + } + req.Header.Set("Accept", "application/json") + + start := time.Now() + resp, err := p.client.Do(req) + if err != nil { + errType := "network" + if errors.Is(err, context.DeadlineExceeded) { + errType = "timeout" + } + RecordOtherError(p.Slug(), p.region, errType) + return 0, false, fmt.Errorf("raydium http: %w", err) + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusTooManyRequests: + _, _ = io.Copy(io.Discard, resp.Body) + RecordThrottled(p.Slug(), p.region) + return 0, false, fmt.Errorf("raydium http 429") + case http.StatusUnauthorized, http.StatusForbidden: + _, _ = io.Copy(io.Discard, resp.Body) + RecordAuthError(p.Slug(), p.region) + return 0, false, fmt.Errorf("raydium http %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + RecordOtherError(p.Slug(), p.region, "read_body") + return 0, false, fmt.Errorf("read body: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + var parsed raydiumQuoteResp + _ = json.Unmarshal(body, &parsed) + if isNoRouteSignal("", parsed.Msg) || strings.Contains(strings.ToLower(string(body)), "no route") { + RecordNoRoute(p.Slug(), p.region) + return 0, false, fmt.Errorf("raydium no route for %s", tokenOut.Symbol) + } + RecordOtherError(p.Slug(), p.region, fmt.Sprintf("http_%d", resp.StatusCode)) + return 0, false, fmt.Errorf("raydium http %d: %s", resp.StatusCode, parsed.Msg) + } + + var parsed raydiumQuoteResp + if err := json.Unmarshal(body, &parsed); err != nil { + RecordOtherError(p.Slug(), p.region, "parse") + return 0, false, fmt.Errorf("parse: %w", err) + } + if !parsed.Success { + if isNoRouteSignal("", parsed.Msg) || parsed.Msg == "" { + // Raydium returns success=false with msg empty or "route not found" + // when the token isn't in any Raydium pool. Treat all success=false + // as no-route for the metric (it's an AMM limitation, not a server + // error). + RecordNoRoute(p.Slug(), p.region) + return 0, false, fmt.Errorf("raydium no route for %s: %s", tokenOut.Symbol, parsed.Msg) + } + RecordOtherError(p.Slug(), p.region, "api_not_success") + return 0, false, fmt.Errorf("raydium success=false: %s", parsed.Msg) + } + if parsed.Data.OutputAmount == "" { + RecordNoRoute(p.Slug(), p.region) + return 0, false, fmt.Errorf("raydium empty data.outputAmount for %s", tokenOut.Symbol) + } + + latencyMs := time.Since(start).Milliseconds() + return latencyMs, true, nil +} diff --git a/harnesses/solana-quote-latency/cmd/monitor/scheduler.go b/harnesses/solana-quote-latency/cmd/monitor/scheduler.go new file mode 100644 index 00000000..e02287bf --- /dev/null +++ b/harnesses/solana-quote-latency/cmd/monitor/scheduler.go @@ -0,0 +1,135 @@ +package main + +import ( + "context" + "fmt" + "sync" + "time" +) + +const ( + tickInterval = 60 * time.Second + probeTimeout = 10 * time.Second +) + +// buildProviders returns the enabled provider list given the resolved config. +// Public providers (Jupiter, OpenOcean, Raydium) are always on; gated providers +// (Mobula, DFlow) require their API key to be set. +func buildProviders(cfg *Config) []Provider { + providers := []Provider{ + NewJupiterProvider(cfg.MonitorRegion, cfg.JupiterAPIKey), + NewOpenOceanProvider(cfg.MonitorRegion), + NewRaydiumProvider(cfg.MonitorRegion), + } + if cfg.MobulaAPIKey != "" { + providers = append(providers, NewMobulaProvider(cfg.MonitorRegion, cfg.MobulaAPIKey)) + } + if cfg.DFlowAPIKey != "" { + providers = append(providers, NewDFlowProvider(cfg.MonitorRegion, cfg.DFlowAPIKey)) + } + return providers +} + +// runScheduler ticks every 60s and fans out one Probe per provider in parallel, +// each tick quoting USDC -> a fresh tokenOut picked from the Pulse-fed +// trending pool. Token rotation defeats per-pair edge caching (Jupiter caches +// SOL↔USDC aggressively) and forces every provider to actually search a route. +// +// Rotation source priority: +// 1. Pulse V2 WS bonded view (preferred — refreshed millisecond-by-millisecond +// with the tokens actually trending right now on Solana). +// 2. REST market-query snapshot (fallback — kept warm on a 10-min cron so the +// bench survives a Pulse outage). +// +// Pick() inside TrendingFetcher does the priority selection; scheduler never +// has to branch on which source is currently live. +func runScheduler(cfg *Config, stopChan <-chan struct{}) { + providers := buildProviders(cfg) + fmt.Printf("[SCHED] starting with %d providers, region=%s, tick=%s\n", + len(providers), cfg.MonitorRegion, tickInterval) + for _, p := range providers { + fmt.Printf("[SCHED] - %s\n", p.Slug()) + } + + if cfg.MobulaAPIKey == "" { + fmt.Println("[SCHED] FATAL: MOBULA_API_KEY is required to feed the trending rotation; aborting") + return + } + + trending := NewTrendingFetcher(cfg.MobulaAPIKey) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Pulse WS — primary rotation source. + pulse := NewPulseSubscriber(cfg.MobulaAPIKey, trending) + go pulse.Run(ctx) + + // REST snapshot — fallback rotation source. + go trending.RunREST(ctx) + + // Wait up to 30s for at least one rotation entry to land. Either source + // arriving first is fine — Pulse usually wins (sub-second once dialed) but + // REST will fill in within ~1s of the first /market/query response. + deadline := time.Now().Add(30 * time.Second) + for { + if _, ok := trending.Pick(); ok { + break + } + if time.Now().After(deadline) { + fmt.Println("[SCHED] FATAL: rotation pool never loaded (Pulse WS + REST both down for 30s); aborting") + return + } + time.Sleep(500 * time.Millisecond) + } + + // First tick fires immediately so we don't wait 60s for the first datapoint. + runTick(providers, cfg.MonitorRegion, trending, pulse) + + ticker := time.NewTicker(tickInterval) + defer ticker.Stop() + + for { + select { + case <-stopChan: + fmt.Println("[SCHED] stop signal received") + return + case <-ticker.C: + runTick(providers, cfg.MonitorRegion, trending, pulse) + } + } +} + +func runTick(providers []Provider, region string, trending *TrendingFetcher, pulse *PulseSubscriber) { + tokenOut, ok := trending.Pick() + if !ok { + fmt.Println("[SCHED] tick skipped: rotation pool empty") + return + } + src := "REST" + if pulse.IsConnected() { + src = "PULSE" + } + pulseLive, restCount := trending.Stats() + fmt.Printf("[SCHED] tick: USDC -> %s (%s) — src=%s pool: pulse=%d rest=%d\n", + tokenOut.Symbol, tokenOut.Mint, src, pulseLive, restCount) + + var wg sync.WaitGroup + for _, p := range providers { + wg.Add(1) + go func(p Provider) { + defer wg.Done() + ctx, cancel := context.WithTimeout(context.Background(), probeTimeout) + defer cancel() + + latencyMs, ok, err := p.Probe(ctx, tokenOut) + RecordSuccess(p.Slug(), region, ok) + if !ok { + fmt.Printf("[SCHED][%s] FAIL (%s): %v\n", p.Slug(), tokenOut.Symbol, err) + return + } + RecordLatency(p.Slug(), region, latencyMs) + fmt.Printf("[SCHED][%s] ok %s latency=%dms\n", p.Slug(), tokenOut.Symbol, latencyMs) + }(p) + } + wg.Wait() +} diff --git a/harnesses/solana-quote-latency/cmd/monitor/trending.go b/harnesses/solana-quote-latency/cmd/monitor/trending.go new file mode 100644 index 00000000..935ec23e --- /dev/null +++ b/harnesses/solana-quote-latency/cmd/monitor/trending.go @@ -0,0 +1,264 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "math/rand" + "net/http" + "net/url" + "sync" + "time" +) + +// TrendingToken is one rotation entry. Symbol is for logs / debugging only; +// the mint is the canonical identity passed to every provider adapter. +type TrendingToken struct { + Mint string + Symbol string +} + +// TrendingFetcher is the rotation source the scheduler reads from. It holds +// two parallel populations: +// +// - "pulse pool" — fed by PulseSubscriber via Note() / Forget() from the +// live `wss://pulse-v2-api.mobula.io` bonded view. Each entry has a +// `lastSeen` timestamp and is evicted from Pick after 30 min of silence. +// This is the preferred source — it tracks what's actually trending right +// now, refreshed on the millisecond. +// +// - "rest fallback" — the older list fetched from +// `api.mobula.io/api/1/market/query?sortBy=volume&offset=5&limit=50` every +// 10 min. Used ONLY when the Pulse WS hasn't seen a message in > 90 s +// (initial connection delay, network blip, Mobula incident). Without this +// safety net the bench would stall during any Pulse outage. +// +// Pick() prefers pulse when it has fresh entries and falls through to the +// REST snapshot otherwise. The scheduler never has to branch. +const ( + trendingFetchEndpoint = "https://api.mobula.io/api/1/market/query" + trendingSkipTop = 5 // skip USDC, USDT, CBBTC, SOL, USD1 + trendingFetchLimit = 50 + trendingRefreshEvery = 10 * time.Minute + trendingMinLiquidity = 50_000.0 + pulseEntryTTL = 30 * time.Minute // entries older than this stop appearing in Pick +) + +// stablecoinSymbols mirrors the stablecoin filter applied to the REST snapshot +// — the Pulse bonded view occasionally emits a stable graduating from a +// launchpad too, and quoting USDC → USDC defeats the purpose. +var stablecoinSymbols = map[string]bool{ + "USDC": true, "USDT": true, "USD1": true, "USDG": true, "USDE": true, + "DAI": true, "FDUSD": true, "PYUSD": true, "USDD": true, "USDS": true, +} + +type mobulaQueryResp []struct { + Symbol string `json:"symbol"` + Liquidity float64 `json:"liquidity"` + Contracts []struct { + Address string `json:"address"` + Blockchain string `json:"blockchain"` + } `json:"contracts"` +} + +type pulseEntry struct { + mint string + symbol string + lastSeen time.Time +} + +type TrendingFetcher struct { + apiKey string + client *http.Client + + mu sync.RWMutex + // rest snapshot (background refresh from /market/query) + restTokens []TrendingToken + // pulse-fed pool keyed by mint + pulse map[string]*pulseEntry + + // lastRESTLoad / lastPulseAt track how fresh each source is. + lastRESTLoad time.Time +} + +func NewTrendingFetcher(apiKey string) *TrendingFetcher { + return &TrendingFetcher{ + apiKey: apiKey, + client: newWarmHTTPClient(), + pulse: make(map[string]*pulseEntry), + } +} + +// Note records that the Pulse bonded view saw this token. Called from the WS +// subscriber's goroutine on every `update-token` / `new-token` event. Skips +// stablecoin symbols and the canonical pair anchors (SOL, USDC). +func (f *TrendingFetcher) Note(mint, symbol string) { + if mint == "" || mint == solMint || mint == usdcMint { + return + } + if stablecoinSymbols[symbol] { + return + } + now := time.Now() + f.mu.Lock() + if e, ok := f.pulse[mint]; ok { + e.lastSeen = now + if e.symbol == "" && symbol != "" { + e.symbol = symbol + } + } else { + f.pulse[mint] = &pulseEntry{mint: mint, symbol: symbol, lastSeen: now} + } + f.mu.Unlock() +} + +// Forget drops a token from the pulse pool. Called when Pulse emits +// `remove-token` (the bonded view shrunk past this entry). +func (f *TrendingFetcher) Forget(mint string) { + f.mu.Lock() + delete(f.pulse, mint) + f.mu.Unlock() +} + +// pulseLive returns the subset of pulse entries seen within pulseEntryTTL. +// Also opportunistically prunes anything older. +func (f *TrendingFetcher) pulseLive() []TrendingToken { + now := time.Now() + cutoff := now.Add(-pulseEntryTTL) + out := make([]TrendingToken, 0, len(f.pulse)) + for mint, e := range f.pulse { + if e.lastSeen.Before(cutoff) { + delete(f.pulse, mint) + continue + } + out = append(out, TrendingToken{Mint: e.mint, Symbol: e.symbol}) + } + return out +} + +// Pick returns one random token from the freshest available source. Tries the +// Pulse pool first; falls through to the REST snapshot when Pulse is empty. +func (f *TrendingFetcher) Pick() (TrendingToken, bool) { + f.mu.Lock() + defer f.mu.Unlock() + if live := f.pulseLive(); len(live) > 0 { + return live[rand.Intn(len(live))], true + } + if len(f.restTokens) > 0 { + return f.restTokens[rand.Intn(len(f.restTokens))], true + } + return TrendingToken{}, false +} + +// Stats returns counts for logging. +func (f *TrendingFetcher) Stats() (pulseLive, restCount int) { + f.mu.RLock() + defer f.mu.RUnlock() + cutoff := time.Now().Add(-pulseEntryTTL) + for _, e := range f.pulse { + if !e.lastSeen.Before(cutoff) { + pulseLive++ + } + } + restCount = len(f.restTokens) + return +} + +// RefreshREST pulls the long-tail-by-volume snapshot. Used as fallback when +// the Pulse WS is down. Keeps the prior list on failure so we don't stall. +func (f *TrendingFetcher) RefreshREST(ctx context.Context) error { + q := url.Values{} + q.Set("sortBy", "volume") + q.Set("sortOrder", "desc") + q.Set("blockchain", "Solana") + q.Set("limit", fmt.Sprintf("%d", trendingFetchLimit)) + q.Set("offset", fmt.Sprintf("%d", trendingSkipTop)) + + fullURL := trendingFetchEndpoint + "?" + q.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + req.Header.Set("Accept", "application/json") + if f.apiKey != "" { + req.Header.Set("Authorization", f.apiKey) + } + + resp, err := f.client.Do(req) + if err != nil { + errType := "network" + if errors.Is(err, context.DeadlineExceeded) { + errType = "timeout" + } + return fmt.Errorf("rest fetch (%s): %w", errType, err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read body: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("rest http %d", resp.StatusCode) + } + + var parsed mobulaQueryResp + if err := json.Unmarshal(body, &parsed); err != nil { + return fmt.Errorf("parse: %w", err) + } + + var out []TrendingToken + for _, t := range parsed { + if stablecoinSymbols[t.Symbol] { + continue + } + if t.Liquidity < trendingMinLiquidity { + continue + } + var solAddr string + for _, c := range t.Contracts { + if c.Blockchain == "solana:solana" { + solAddr = c.Address + break + } + } + if solAddr == "" || solAddr == solMint || solAddr == usdcMint { + continue + } + out = append(out, TrendingToken{Mint: solAddr, Symbol: t.Symbol}) + } + if len(out) == 0 { + return fmt.Errorf("rest: 0 tradable tokens after filter") + } + + f.mu.Lock() + f.restTokens = out + f.lastRESTLoad = time.Now() + f.mu.Unlock() + fmt.Printf("[TRENDING][REST] fallback list refreshed: %d tokens\n", len(out)) + return nil +} + +// RunREST keeps the REST snapshot warm on a 10-min cycle. The Pulse WS feeds +// the primary pool; this is the safety net. +func (f *TrendingFetcher) RunREST(ctx context.Context) { + doRefresh := func() { + c, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + if err := f.RefreshREST(c); err != nil { + fmt.Printf("[TRENDING][REST] refresh failed: %v (keeping prior list)\n", err) + } + } + doRefresh() + t := time.NewTicker(trendingRefreshEvery) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + doRefresh() + } + } +} diff --git a/harnesses/solana-quote-latency/go.mod b/harnesses/solana-quote-latency/go.mod new file mode 100644 index 00000000..c777452a --- /dev/null +++ b/harnesses/solana-quote-latency/go.mod @@ -0,0 +1,21 @@ +module mobula_solana_quote_latency + +go 1.24.4 + +require ( + 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/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/solana-quote-latency/go.sum b/harnesses/solana-quote-latency/go.sum new file mode 100644 index 00000000..02807ba4 --- /dev/null +++ b/harnesses/solana-quote-latency/go.sum @@ -0,0 +1,48 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/harnesses/solana-tx-landing/cmd/script/senders.go b/harnesses/solana-tx-landing/cmd/script/senders.go index b1b74070..e0d88ffd 100644 --- a/harnesses/solana-tx-landing/cmd/script/senders.go +++ b/harnesses/solana-tx-landing/cmd/script/senders.go @@ -87,6 +87,10 @@ func buildProbes(enabled map[Service]bool) ([]serviceProbe, error) { buildNozomiProbe(tipWallets[ServiceNozomi]), buildAstralaneProbe(tipWallets[ServiceAstralane]), buildZeroSlotProbe(tipWallets[Service0slot]), + // Mobula has no own tip wallet (relays via Jito/Nozomi/zeroslot internally). + // Embed a Jito tip transfer so Mobula's default routing sees a valid tip; + // attribution uses signatureSubscribe (active probe path), not tip-wallet. + buildMobulaProbe(tipWallets[ServiceJito]), } out := make([]serviceProbe, 0, len(candidates)) @@ -273,7 +277,10 @@ func buildAstralaneProbe(tip solana.PublicKey) serviceProbe { } host := envDefault("ASTRALANE_ENDPOINT", "https://ny.gateway.astralane.io/iris") url := host + "?api-key=" + key - tipLamports := uint64(envInt("ASTRALANE_TIP_LAMPORTS", 500_000)) + // Astralane raised their min tip floor 500_000 → 1_000_000 lamports + // (observed 2026-05-26 via prober alerts). Default to the new floor; + // the ASTRALANE_TIP_LAMPORTS env var still overrides. + tipLamports := uint64(envInt("ASTRALANE_TIP_LAMPORTS", 1_000_000)) return serviceProbe{ Service: ServiceAstralane, @@ -436,3 +443,109 @@ func truncate(s string, n int) string { } return s[:n] + "…" } + +// ----------------------------------------------------------------------------- +// Mobula swap-send — multi-RPC fan-out aggregator +// docs.mobula.io/rest-api-reference/endpoint/swap-send +// +// Wire shape (verified 2026-05-27): +// - POST https://api.mobula.io/api/2/swap/send +// - Authorization: Bearer +// - Body: {"chainId": "solana:solana", "signedTransaction": ""} +// - Response on success: {"data": {"transactionHash": "", "requestId": "...", +// "success": true, "lander": "jito|nozomi|..."}} +// - 201 returned for accepted submission; downstream failure surfaces inside +// the JSON body via `error` / `data.success=false`. +// +// Mobula relays internally to Jito / Nozomi / zeroslot via multi-RPC fan-out, +// so there is no dedicated Mobula tip wallet. The probe embeds a Jito tip +// transfer (same wallet as the Jito control probe) so Mobula's default +// routing sees a valid tip on the underlying lander. Active-probe attribution +// works via signatureSubscribe on the tx signature, independent of the tip +// wallet used. +// ----------------------------------------------------------------------------- + +func buildMobulaProbe(tip solana.PublicKey) serviceProbe { + key := strings.TrimSpace(os.Getenv("MOBULA_API_KEY")) + if key == "" { + fmt.Println("[prober] mobula: MOBULA_API_KEY not set — service skipped") + return serviceProbe{} + } + url := envDefault("MOBULA_ENDPOINT", "https://api.mobula.io/api/2/swap/send") + tipLamports := uint64(envInt("MOBULA_TIP_LAMPORTS", 10_000)) + + return serviceProbe{ + Service: ServiceMobula, + Mode: "default", + Endpoint: url, + TipWallet: tip, + TipLamports: tipLamports, + Sender: func(ctx context.Context, tx *solana.Transaction) (string, error) { + return postMobulaSwapSend(ctx, url, key, tx) + }, + } +} + +func postMobulaSwapSend(ctx context.Context, url, apiKey string, tx *solana.Transaction) (string, error) { + txB64, err := txToBase64(tx) + if err != nil { + return "", err + } + reqBody := map[string]any{ + "chainId": "solana:solana", + "signedTransaction": txB64, + } + buf, err := json.Marshal(reqBody) + if err != nil { + return "", fmt.Errorf("marshal req: %w", err) + } + cctx, cancel := context.WithTimeout(ctx, senderHTTPTimeout) + defer cancel() + req, err := http.NewRequestWithContext(cctx, http.MethodPost, url, bytes.NewReader(buf)) + if err != nil { + return "", fmt.Errorf("new req: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+apiKey) + + resp, err := senderHTTPClient.Do(req) + if err != nil { + return "", fmt.Errorf("http: %s", scrubSecrets(err.Error())) + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + + if resp.StatusCode == 419 || resp.StatusCode == 429 { + return "", fmt.Errorf("http %d (rate limit): %s", + resp.StatusCode, scrubSecrets(truncate(string(respBody), 200))) + } + if resp.StatusCode >= 500 { + return "", fmt.Errorf("http %d: %s", + resp.StatusCode, scrubSecrets(truncate(string(respBody), 200))) + } + + var parsed struct { + Data struct { + TransactionHash string `json:"transactionHash"` + Success bool `json:"success"` + } `json:"data"` + Error string `json:"error"` + } + if err := json.Unmarshal(respBody, &parsed); err != nil { + return "", fmt.Errorf("decode resp (status=%d body=%s): %w", + resp.StatusCode, scrubSecrets(truncate(string(respBody), 200)), err) + } + if parsed.Error != "" { + return "", fmt.Errorf("mobula: %s", scrubSecrets(parsed.Error)) + } + if parsed.Data.TransactionHash != "" { + return parsed.Data.TransactionHash, nil + } + // Mobula sometimes returns 201 without echoing the sig. Fall back to + // the locally-computed signature (known once tx.Sign ran). + if len(tx.Signatures) == 0 { + return "", fmt.Errorf("no signature in response or on tx (status=%d body=%s)", + resp.StatusCode, scrubSecrets(truncate(string(respBody), 200))) + } + return tx.Signatures[0].String(), nil +} diff --git a/harnesses/solana-tx-landing/cmd/script/wallets.go b/harnesses/solana-tx-landing/cmd/script/wallets.go index 55db22e8..94e1d995 100644 --- a/harnesses/solana-tx-landing/cmd/script/wallets.go +++ b/harnesses/solana-tx-landing/cmd/script/wallets.go @@ -28,6 +28,10 @@ const ( ServiceNextBlock Service = "nextblock" ServiceAstralane Service = "astralane" ServiceSolanaVibeStation Service = "solanavibestation" + // Mobula swap-send. No dedicated tip wallets — Mobula relays internally + // to Jito/Nozomi/zeroslot via multi-RPC fan-out. Not observable via + // tip-wallet attribution (bench № 016); active-probe-only (bench № 027). + ServiceMobula Service = "mobula" ) // walletToService maps every known tip address to its owning service. diff --git a/package.json b/package.json index ff2dbcba..a548de37 100644 --- a/package.json +++ b/package.json @@ -16,12 +16,14 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", + "@react-three/fiber": "^9.6.1", "js-yaml": "^4.1.1", "lucide-react": "^1.11.0", "mcp-handler": "^1.1.0", "next": "16.2.6", "react": "19.2.4", "react-dom": "19.2.4", + "three": "^0.184.0", "zod": "^4.3.6" }, "devDependencies": { @@ -31,6 +33,7 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@types/three": "^0.184.1", "eslint": "^9", "eslint-config-next": "16.2.4", "tailwindcss": "^4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6496de2a..72ee3b83 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@modelcontextprotocol/sdk': specifier: ^1.26.0 version: 1.26.0(zod@4.3.6) + '@react-three/fiber': + specifier: ^9.6.1 + version: 9.6.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.184.0) js-yaml: specifier: ^4.1.1 version: 4.1.1 @@ -32,6 +35,9 @@ importers: react-dom: specifier: 19.2.4 version: 19.2.4(react@19.2.4) + three: + specifier: ^0.184.0 + version: 0.184.0 zod: specifier: ^4.3.6 version: 4.3.6 @@ -54,6 +60,9 @@ importers: '@types/react-dom': specifier: ^19 version: 19.2.3(@types/react@19.2.14) + '@types/three': + specifier: ^0.184.1 + version: 0.184.1 eslint: specifier: ^9 version: 9.39.4(jiti@2.6.1) @@ -131,6 +140,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} @@ -143,6 +156,9 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@dimforge/rapier3d-compat@0.12.0': + resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -628,6 +644,31 @@ packages: resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} engines: {node: '>=12.4.0'} + '@react-three/fiber@9.6.1': + resolution: {integrity: sha512-zF0rsKcVYpcJwbFEnv2HkHX9cvOEgsfQo/X8lwmR2dn13S4qEQJXir9fxf5js2LQFoXqxOY7MDkOkYx2uZ4gSg==} + peerDependencies: + expo: '>=43.0' + expo-asset: '>=8.4' + expo-file-system: '>=11.0' + expo-gl: '>=11.0' + react: '>=19 <19.3' + react-dom: '>=19 <19.3' + react-native: '>=0.78' + three: '>=0.156' + peerDependenciesMeta: + expo: + optional: true + expo-asset: + optional: true + expo-file-system: + optional: true + expo-gl: + optional: true + react-dom: + optional: true + react-native: + optional: true + '@redis/bloom@1.2.0': resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==} peerDependencies: @@ -755,6 +796,9 @@ packages: '@tailwindcss/postcss@4.2.4': resolution: {integrity: sha512-wgAVj6nUWAolAu8YFvzT2cTBIElWHkjZwFYovF+xsqKsW2ADxM/X2opxj5NsF/qVccAOjRNe8X2IdPzMsWyHTg==} + '@tweenjs/tween.js@23.1.3': + resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -781,9 +825,23 @@ packages: peerDependencies: '@types/react': ^19.2.0 + '@types/react-reconciler@0.28.9': + resolution: {integrity: sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==} + peerDependencies: + '@types/react': '*' + '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/stats.js@0.17.4': + resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==} + + '@types/three@0.184.1': + resolution: {integrity: sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==} + + '@types/webxr@0.5.24': + resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} + '@typescript-eslint/eslint-plugin@8.59.1': resolution: {integrity: sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1043,6 +1101,9 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.10.23: resolution: {integrity: sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g==} engines: {node: '>=6.0.0'} @@ -1068,6 +1129,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + bun-types@1.3.14: resolution: {integrity: sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ==} @@ -1451,6 +1515,9 @@ packages: picomatch: optional: true + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -1600,6 +1667,9 @@ packages: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1747,6 +1817,11 @@ packages: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} + its-fine@2.0.0: + resolution: {integrity: sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==} + peerDependencies: + react: ^19.0.0 + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -1930,6 +2005,9 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + meshoptimizer@1.1.1: + resolution: {integrity: sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -2138,6 +2216,15 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-use-measure@2.1.7: + resolution: {integrity: sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==} + peerDependencies: + react: '>=16.13' + react-dom: '>=16.13' + peerDependenciesMeta: + react-dom: + optional: true + react@19.2.4: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} @@ -2325,6 +2412,11 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + suspend-react@0.1.3: + resolution: {integrity: sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==} + peerDependencies: + react: '>=17.0' + tailwindcss@4.2.4: resolution: {integrity: sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==} @@ -2332,6 +2424,9 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} + three@0.184.0: + resolution: {integrity: sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==} + tinyglobby@0.2.16: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} @@ -2420,6 +2515,11 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -2476,6 +2576,24 @@ packages: zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zustand@5.0.14: + resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + snapshots: '@alloc/quick-lru@5.2.0': {} @@ -2557,6 +2675,8 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@babel/runtime@7.29.7': {} + '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 @@ -2580,6 +2700,8 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@dimforge/rapier3d-compat@0.12.0': {} + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -2929,6 +3051,26 @@ snapshots: '@nolyfill/is-core-module@1.0.39': {} + '@react-three/fiber@9.6.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.184.0)': + dependencies: + '@babel/runtime': 7.29.7 + '@types/webxr': 0.5.24 + base64-js: 1.5.1 + buffer: 6.0.3 + its-fine: 2.0.0(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-use-measure: 2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + scheduler: 0.27.0 + suspend-react: 0.1.3(react@19.2.4) + three: 0.184.0 + use-sync-external-store: 1.6.0(react@19.2.4) + zustand: 5.0.14(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)) + optionalDependencies: + react-dom: 19.2.4(react@19.2.4) + transitivePeerDependencies: + - '@types/react' + - immer + '@redis/bloom@1.2.0(@redis/client@1.6.1)': dependencies: '@redis/client': 1.6.1 @@ -3030,6 +3172,8 @@ snapshots: postcss: 8.5.12 tailwindcss: 4.2.4 + '@tweenjs/tween.js@23.1.3': {} + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -3055,10 +3199,27 @@ snapshots: dependencies: '@types/react': 19.2.14 + '@types/react-reconciler@0.28.9(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + '@types/react@19.2.14': dependencies: csstype: 3.2.3 + '@types/stats.js@0.17.4': {} + + '@types/three@0.184.1': + dependencies: + '@dimforge/rapier3d-compat': 0.12.0 + '@tweenjs/tween.js': 23.1.3 + '@types/stats.js': 0.17.4 + '@types/webxr': 0.5.24 + fflate: 0.8.3 + meshoptimizer: 1.1.1 + + '@types/webxr@0.5.24': {} + '@typescript-eslint/eslint-plugin@8.59.1(@typescript-eslint/parser@8.59.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -3329,6 +3490,8 @@ snapshots: balanced-match@4.0.4: {} + base64-js@1.5.1: {} + baseline-browser-mapping@2.10.23: {} body-parser@2.2.2: @@ -3366,6 +3529,11 @@ snapshots: node-releases: 2.0.38 update-browserslist-db: 1.2.3(browserslist@4.28.2) + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + bun-types@1.3.14: dependencies: '@types/node': 20.19.39 @@ -3919,6 +4087,8 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fflate@0.8.3: {} + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -4071,6 +4241,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 + ieee754@1.2.1: {} + ignore@5.3.2: {} ignore@7.0.5: {} @@ -4221,6 +4393,13 @@ snapshots: has-symbols: 1.1.0 set-function-name: 2.0.2 + its-fine@2.0.0(@types/react@19.2.14)(react@19.2.4): + dependencies: + '@types/react-reconciler': 0.28.9(@types/react@19.2.14) + react: 19.2.4 + transitivePeerDependencies: + - '@types/react' + jiti@2.6.1: {} jose@6.2.3: {} @@ -4359,6 +4538,8 @@ snapshots: merge2@1.4.1: {} + meshoptimizer@1.1.1: {} + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -4563,6 +4744,12 @@ snapshots: react-is@16.13.1: {} + react-use-measure@2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + optionalDependencies: + react-dom: 19.2.4(react@19.2.4) + react@19.2.4: {} redis@4.7.1: @@ -4845,10 +5032,16 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + suspend-react@0.1.3(react@19.2.4): + dependencies: + react: 19.2.4 + tailwindcss@4.2.4: {} tapable@2.3.3: {} + three@0.184.0: {} + tinyglobby@0.2.16: dependencies: fdir: 6.5.0(picomatch@4.0.4) @@ -4981,6 +5174,10 @@ snapshots: dependencies: punycode: 2.3.1 + use-sync-external-store@1.6.0(react@19.2.4): + dependencies: + react: 19.2.4 + vary@1.1.2: {} which-boxed-primitive@1.1.1: @@ -5047,3 +5244,9 @@ snapshots: zod: 4.3.6 zod@4.3.6: {} + + zustand@5.0.14(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)): + optionalDependencies: + '@types/react': 19.2.14 + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) diff --git a/public/logos/1rpc.svg b/public/logos/1rpc.svg index 90e7f453..5494a3e3 100644 --- a/public/logos/1rpc.svg +++ b/public/logos/1rpc.svg @@ -1,4 +1,4 @@ - + diff --git a/public/logos/apexliquid.png b/public/logos/apexliquid.png new file mode 100644 index 00000000..cb141c3a Binary files /dev/null and b/public/logos/apexliquid.png differ diff --git a/public/logos/arena.png b/public/logos/arena.png new file mode 100644 index 00000000..86f113ed Binary files /dev/null and b/public/logos/arena.png differ diff --git a/public/logos/axiom.png b/public/logos/axiom.png new file mode 100644 index 00000000..f0b82975 Binary files /dev/null and b/public/logos/axiom.png differ diff --git a/public/logos/based-app.png b/public/logos/based-app.png new file mode 100644 index 00000000..2a561786 Binary files /dev/null and b/public/logos/based-app.png differ diff --git a/public/logos/blocknative.svg b/public/logos/blocknative.svg index 75bc8d64..815dc90b 100644 --- a/public/logos/blocknative.svg +++ b/public/logos/blocknative.svg @@ -1 +1,38 @@ - \ No newline at end of file + + + + + + + + + + + + + + + + + + diff --git a/public/logos/bullpen.svg b/public/logos/bullpen.svg new file mode 100644 index 00000000..31151bb6 --- /dev/null +++ b/public/logos/bullpen.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public/logos/coin98.png b/public/logos/coin98.png new file mode 100644 index 00000000..c1b73f55 Binary files /dev/null and b/public/logos/coin98.png differ diff --git a/public/logos/coinpaprika.svg b/public/logos/coinpaprika.svg new file mode 100644 index 00000000..122b7608 --- /dev/null +++ b/public/logos/coinpaprika.svg @@ -0,0 +1 @@ +LOGO_NMAsset 1 diff --git a/public/logos/coinpilot.png b/public/logos/coinpilot.png new file mode 100644 index 00000000..0458d808 Binary files /dev/null and b/public/logos/coinpilot.png differ diff --git a/public/logos/coinstats.svg b/public/logos/coinstats.svg new file mode 100644 index 00000000..dd88ae09 --- /dev/null +++ b/public/logos/coinstats.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/logos/covalent.svg b/public/logos/covalent.svg new file mode 100644 index 00000000..76d2cfce --- /dev/null +++ b/public/logos/covalent.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/logos/defiapp.svg b/public/logos/defiapp.svg new file mode 100644 index 00000000..98be168f --- /dev/null +++ b/public/logos/defiapp.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/logos/dexari.png b/public/logos/dexari.png new file mode 100644 index 00000000..3de07938 Binary files /dev/null and b/public/logos/dexari.png differ diff --git a/public/logos/dexly-trade.svg b/public/logos/dexly-trade.svg new file mode 100644 index 00000000..2459ff6e --- /dev/null +++ b/public/logos/dexly-trade.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/logos/dreamcash.png b/public/logos/dreamcash.png new file mode 100644 index 00000000..c291da34 Binary files /dev/null and b/public/logos/dreamcash.png differ diff --git a/public/logos/dune.png b/public/logos/dune.png new file mode 100644 index 00000000..2e51e70e Binary files /dev/null and b/public/logos/dune.png differ diff --git a/public/logos/dune.svg b/public/logos/dune.svg deleted file mode 100644 index a33d329e..00000000 --- a/public/logos/dune.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/public/logos/echosync.png b/public/logos/echosync.png new file mode 100644 index 00000000..6786ce97 Binary files /dev/null and b/public/logos/echosync.png differ diff --git a/public/logos/flowbot.jpg b/public/logos/flowbot.jpg new file mode 100644 index 00000000..e3e7510c Binary files /dev/null and b/public/logos/flowbot.jpg differ diff --git a/public/logos/fomo.png b/public/logos/fomo.png new file mode 100644 index 00000000..75e342f1 Binary files /dev/null and b/public/logos/fomo.png differ diff --git a/public/logos/gemwallet.svg b/public/logos/gemwallet.svg new file mode 100644 index 00000000..8f9c1b5b --- /dev/null +++ b/public/logos/gemwallet.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/logos/grider.jpg b/public/logos/grider.jpg new file mode 100644 index 00000000..8c34b28c Binary files /dev/null and b/public/logos/grider.jpg differ diff --git a/public/logos/gtr-trade.png b/public/logos/gtr-trade.png new file mode 100644 index 00000000..25118385 Binary files /dev/null and b/public/logos/gtr-trade.png differ diff --git a/public/logos/hyperdash.jpg b/public/logos/hyperdash.jpg new file mode 100644 index 00000000..0a09ee6a Binary files /dev/null and b/public/logos/hyperdash.jpg differ diff --git a/public/logos/hyprearn.png b/public/logos/hyprearn.png new file mode 100644 index 00000000..7a185931 Binary files /dev/null and b/public/logos/hyprearn.png differ diff --git a/public/logos/infinex.jpg b/public/logos/infinex.jpg new file mode 100644 index 00000000..0212a691 Binary files /dev/null and b/public/logos/infinex.jpg differ diff --git a/public/logos/insilico.svg b/public/logos/insilico.svg new file mode 100644 index 00000000..f1019d51 --- /dev/null +++ b/public/logos/insilico.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/logos/katoshi.svg b/public/logos/katoshi.svg new file mode 100644 index 00000000..51080e82 --- /dev/null +++ b/public/logos/katoshi.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/logos/lava.webp b/public/logos/lava.webp index 4c90a195..8b4c0076 100644 Binary files a/public/logos/lava.webp and b/public/logos/lava.webp differ diff --git a/public/logos/legend-trade.png b/public/logos/legend-trade.png new file mode 100644 index 00000000..8aa18106 Binary files /dev/null and b/public/logos/legend-trade.png differ diff --git a/public/logos/liminal.jpg b/public/logos/liminal.jpg new file mode 100644 index 00000000..894c7778 Binary files /dev/null and b/public/logos/liminal.jpg differ diff --git a/public/logos/liquid-perps.jpg b/public/logos/liquid-perps.jpg new file mode 100644 index 00000000..5ad297bd Binary files /dev/null and b/public/logos/liquid-perps.jpg differ diff --git a/public/logos/lit-trade.png b/public/logos/lit-trade.png new file mode 100644 index 00000000..cf60cc4e Binary files /dev/null and b/public/logos/lit-trade.png differ diff --git a/public/logos/lootbase.png b/public/logos/lootbase.png new file mode 100644 index 00000000..41257e3b Binary files /dev/null and b/public/logos/lootbase.png differ diff --git a/public/logos/mass-dot-money.svg b/public/logos/mass-dot-money.svg new file mode 100644 index 00000000..84f41524 --- /dev/null +++ b/public/logos/mass-dot-money.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/logos/metamask.svg b/public/logos/metamask.svg new file mode 100644 index 00000000..47ccb58a --- /dev/null +++ b/public/logos/metamask.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/logos/metascalp.svg b/public/logos/metascalp.svg new file mode 100644 index 00000000..6f7ad8a1 --- /dev/null +++ b/public/logos/metascalp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/logos/minaraai.png b/public/logos/minaraai.png new file mode 100644 index 00000000..5c2d6c53 Binary files /dev/null and b/public/logos/minaraai.png differ diff --git a/public/logos/moonbot.png b/public/logos/moonbot.png new file mode 100644 index 00000000..fa1280ab Binary files /dev/null and b/public/logos/moonbot.png differ diff --git a/public/logos/moontrader.svg b/public/logos/moontrader.svg new file mode 100644 index 00000000..9e15fdaa --- /dev/null +++ b/public/logos/moontrader.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/logos/nautilus-trader.png b/public/logos/nautilus-trader.png new file mode 100644 index 00000000..8bf97ab9 Binary files /dev/null and b/public/logos/nautilus-trader.png differ diff --git a/public/logos/okto.png b/public/logos/okto.png new file mode 100644 index 00000000..c7159696 Binary files /dev/null and b/public/logos/okto.png differ diff --git a/public/logos/onekey.png b/public/logos/onekey.png new file mode 100644 index 00000000..4b0e1833 Binary files /dev/null and b/public/logos/onekey.png differ diff --git a/public/logos/openocean.png b/public/logos/openocean.png new file mode 100644 index 00000000..ee985eb5 Binary files /dev/null and b/public/logos/openocean.png differ diff --git a/public/logos/pear.svg b/public/logos/pear.svg new file mode 100644 index 00000000..8ef27a6d --- /dev/null +++ b/public/logos/pear.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/logos/perpmate.png b/public/logos/perpmate.png new file mode 100644 index 00000000..37e19753 Binary files /dev/null and b/public/logos/perpmate.png differ diff --git a/public/logos/phantom-perps.svg b/public/logos/phantom-perps.svg new file mode 100644 index 00000000..c17833ef --- /dev/null +++ b/public/logos/phantom-perps.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/public/logos/pvp-trade.png b/public/logos/pvp-trade.png new file mode 100644 index 00000000..62af58dd Binary files /dev/null and b/public/logos/pvp-trade.png differ diff --git a/public/logos/rabby.png b/public/logos/rabby.png new file mode 100644 index 00000000..5304e332 Binary files /dev/null and b/public/logos/rabby.png differ diff --git a/public/logos/rainbow.png b/public/logos/rainbow.png new file mode 100644 index 00000000..77303683 Binary files /dev/null and b/public/logos/rainbow.png differ diff --git a/public/logos/ranger-finance.svg b/public/logos/ranger-finance.svg new file mode 100644 index 00000000..dee508e0 --- /dev/null +++ b/public/logos/ranger-finance.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/public/logos/raydium.svg b/public/logos/raydium.svg new file mode 100644 index 00000000..d014e8aa --- /dev/null +++ b/public/logos/raydium.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/logos/senpi.png b/public/logos/senpi.png new file mode 100644 index 00000000..73139847 Binary files /dev/null and b/public/logos/senpi.png differ diff --git a/public/logos/silhouette.jpg b/public/logos/silhouette.jpg new file mode 100644 index 00000000..2a330726 Binary files /dev/null and b/public/logos/silhouette.jpg differ diff --git a/public/logos/supercexy.svg b/public/logos/supercexy.svg new file mode 100644 index 00000000..e233e20b --- /dev/null +++ b/public/logos/supercexy.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public/logos/superstack.jpg b/public/logos/superstack.jpg new file mode 100644 index 00000000..6c7a15e2 Binary files /dev/null and b/public/logos/superstack.jpg differ diff --git a/public/logos/superx.avif b/public/logos/superx.avif new file mode 100644 index 00000000..13091e19 Binary files /dev/null and b/public/logos/superx.avif differ diff --git a/public/logos/supurr.svg b/public/logos/supurr.svg new file mode 100644 index 00000000..6d3be61d --- /dev/null +++ b/public/logos/supurr.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/logos/sushi.png b/public/logos/sushi.png new file mode 100644 index 00000000..8c9f1d93 Binary files /dev/null and b/public/logos/sushi.png differ diff --git a/public/logos/taco-trade.jpg b/public/logos/taco-trade.jpg new file mode 100644 index 00000000..273dffe3 Binary files /dev/null and b/public/logos/taco-trade.jpg differ diff --git a/public/logos/tenderly.svg b/public/logos/tenderly.svg index c0bf3dd8..b3c05826 100644 --- a/public/logos/tenderly.svg +++ b/public/logos/tenderly.svg @@ -1,5 +1,5 @@ - +
  • ·Read the{" "}live benchmarks{" "}.
  • ·Reproduce any number. the{" "}methodology{" "}page tells you how.
  • -
  • ·Contribute a harness via{" "}GitHub. Pull requests for new providers and new benchmarks are reviewed openly.
  • -
  • ·Discuss ideas in{" "}GitHub Discussions{" "}or follow{" "}@openchainbench.
  • +
  • ·Contribute a harness via{" "}GitHub. Pull requests for new providers and new benchmarks are reviewed openly.
  • +
  • ·Discuss ideas in{" "}GitHub Discussions{" "}or follow{" "}@openchainbench.

See a number you can't reproduce? File a{" "} - + data-quality issue or a provider correction . Material errors are corrected in place with a dated note on the affected report. diff --git a/src/app/alternatives/[slug]/page.tsx b/src/app/alternatives/[slug]/page.tsx index 14ae5e17..1488a209 100644 --- a/src/app/alternatives/[slug]/page.tsx +++ b/src/app/alternatives/[slug]/page.tsx @@ -76,6 +76,24 @@ export default async function AlternativePage({ const url = `${SITE.url}/alternatives/${alt.slug}`; const benchUrl = `${SITE.url}/benchmarks/${bench.slug}`; + + // Schema.org / Google Rich Results requires Dataset.description to be + // between 50 and 5000 chars (Search Console flags "Invalid string + // length" below 50). Most short alt.description fields ("Relay + // alternatives", ~40 chars) trip this. Pad with bench context until + // the threshold is met, then truncate at the 5000 ceiling. + const altDescriptionBase = alt.description ?? alt.intro.slice(0, 280); + const altDescriptionPadded = (() => { + if (altDescriptionBase.length >= 50) return altDescriptionBase.slice(0, 5000); + const altCount = topAlternatives.length; + const peers = altCount > 0 + ? `${altCount} live alternative${altCount === 1 ? "" : "s"}` + : "live alternatives"; + const pad = `. Open benchmark snapshot of ${alt.target_product} versus ${peers} on ${bench.metric}, measured continuously and published with full methodology.`; + const joined = `${altDescriptionBase}${pad}`; + return joined.slice(0, 5000); + })(); + const jsonLd = { "@context": "https://schema.org", "@graph": [ @@ -83,7 +101,7 @@ export default async function AlternativePage({ "@type": "Dataset", "@id": `${url}#dataset`, name: `${alt.target_product} alternatives, benchmark snapshot`, - description: alt.description ?? alt.intro.slice(0, 280), + description: altDescriptionPadded, url, identifier: alt.slug, keywords: [ @@ -104,7 +122,7 @@ export default async function AlternativePage({ "@type": "Article", "@id": `${url}#article`, headline: `${alt.target_product} alternatives`, - description: alt.description ?? alt.intro.slice(0, 280), + description: altDescriptionPadded, url, mainEntityOfPage: url, articleBody: alt.intro, diff --git a/src/app/api/badge/[slug]/[provider]/route.ts b/src/app/api/badge/[slug]/[provider]/route.ts index 70fa99bb..d54fbfa7 100644 --- a/src/app/api/badge/[slug]/[provider]/route.ts +++ b/src/app/api/badge/[slug]/[provider]/route.ts @@ -1,18 +1,33 @@ /** * Embeddable SVG badge. One badge per (benchmark, provider). * - * Endpoint shape: /api/badge// + * Endpoint shape: /api/badge//?chain= * * Returns an SVG showing the provider's current rank + headline figure * on that bench. Cache-Control is short so the figure refreshes within * a few minutes of a new run. + * + * The optional `?chain=` query param scopes the rank computation to a + * single chain (e.g. `?chain=solana`). When present, the badge: + * - computes rank within the providers that have a measurement on + * that chain (read from `benchmark.bestPerChain`). + * - prints the chain label as a subscript on the SVG so embedders + * don't mistakenly broadcast a chain-restricted "#1" as a global + * finish (e.g. a Solana-only provider being aggregate #1 mechanically + * on a cross-chain bench). + * + * When NO `chain` is provided, the badge falls back to the unfiltered + * aggregate AND adds an "all chains" textual hint to the SVG so the + * scope of the rank is visible at a glance. */ import { type NextRequest, NextResponse } from "next/server"; import { getBenchmark } from "@/data/benchmarks"; import { fmtUnit } from "@/lib/format"; +import { readBestPerChain } from "@/lib/per-chain-contract"; import { clientKey, rateLimit, tooManyRequests } from "@/lib/rate-limit"; import { PROVIDER_RE, SLUG_RE } from "@/lib/slug"; +import type { Benchmark, ProviderResult } from "@/types/benchmark"; export const revalidate = 300; @@ -42,6 +57,51 @@ function rankOf( return { rank: idx + 1, total: sorted.length, value: sorted[idx].ms.p50 }; } +/** + * Per-chain rank computation. Uses `bestPerChain` (the precomputed leader + * per chain stash from spec.ts) to scope the rank to providers active on + * the requested chain. The leader is forced to rank #1; everyone else is + * ranked by their unfiltered p50 within the providers whose bestPerChain + * entry exists on at least one chain (soft approximation — full per-chain + * leaderboards live on the bench page chain tabs). + * + * Returns null when: + * - the bench has no `bestPerChain` (no chain dimensions on this bench). + * - the chain is unknown or has no leader this cycle. + * - the provider isn't present in the unfiltered results. + */ +function rankOfChain( + b: Benchmark, + providerSlug: string, + chain: string, +): { rank: number; total: number; value: number; leader: ProviderResult } | null { + const bestPerChain = readBestPerChain(b); + if (!bestPerChain) return null; + const leader = bestPerChain[chain]; + if (!leader) return null; + const live = b.results.filter((r) => r.ms.p50 > 0); + if (live.length === 0) return null; + const lower = providerSlug.toLowerCase(); + const others = live.filter( + (r) => r.slug.toLowerCase() !== leader.slug.toLowerCase(), + ); + const sortedOthers = [...others].sort((a, c) => + b.higherIsBetter ? c.ms.p50 - a.ms.p50 : a.ms.p50 - c.ms.p50, + ); + const total = live.length; + if (leader.slug.toLowerCase() === lower) { + return { rank: 1, total, value: leader.ms.p50, leader }; + } + const idx = sortedOthers.findIndex((r) => r.slug.toLowerCase() === lower); + if (idx === -1) return null; + return { + rank: idx + 2, // +1 for 1-index, +1 because leader took slot 1 + total, + value: sortedOthers[idx].ms.p50, + leader, + }; +} + function valueSuffix(unit: string): string { if (unit === "count") return "(24h)"; if (unit === "pct" || unit === "bps") return "(24h avg)"; @@ -53,6 +113,11 @@ function truncate(s: string, max: number): string { return s.slice(0, max - 1).trimEnd() + "…"; } +/** Returns the human label for a chain value from the bench's spec. */ +function chainLabel(b: Benchmark, chain: string): string { + return b.dimensions?.chain?.find((c) => c.value === chain)?.label ?? chain; +} + export async function GET( req: NextRequest, { params }: { params: Promise }, @@ -74,8 +139,46 @@ export async function GET( headers: { "cache-control": "public, s-maxage=60" }, }); } - const r = rankOf(b.results, provider, b.higherIsBetter); - if (!r) return new NextResponse("not found", { status: 404 }); + + // Chain scoping. Query param is normalized to lowercase and validated + // against the bench's declared chain dimensions; an unknown chain is + // treated as a 400 rather than silently falling back to "all", so an + // embedder who mistypes can fix it instead of shipping a misleading + // unfiltered figure under a chain badge. + const url = new URL(req.url); + const chainParam = url.searchParams.get("chain")?.toLowerCase().trim() || null; + if (chainParam) { + const known = b.dimensions?.chain?.some( + (c) => c.value.toLowerCase() === chainParam, + ); + if (!known) { + return new NextResponse("unknown chain", { + status: 400, + headers: { "cache-control": "public, s-maxage=60" }, + }); + } + } + + let r: { rank: number; total: number; value: number } | null; + let scopeLabel: string; + if (chainParam) { + const scoped = rankOfChain(b, provider, chainParam); + if (!scoped) { + return new NextResponse("not found", { status: 404 }); + } + r = { rank: scoped.rank, total: scoped.total, value: scoped.value }; + scopeLabel = chainLabel(b, chainParam); + } else { + r = rankOf(b.results, provider, b.higherIsBetter); + if (!r) return new NextResponse("not found", { status: 404 }); + // Add an "all chains" hint when the bench declares chain dimensions so + // embedders can read the scope. Benches without chain dimensions get + // no scope label (it would be noise). + scopeLabel = + (b.dimensions?.chain?.filter((c) => c.value !== "all").length ?? 0) > 0 + ? "all chains" + : ""; + } // Colour signals rank. green for #1, dark ink for everyone else. const accent = r.rank === 1 ? "#3F7B47" : "#22272F"; @@ -88,24 +191,39 @@ export async function GET( // chip the site uses internally. const ocbBrand = "OCB"; + // Scope marker: rendered as a small subscript next to the rank when + // present. Keeps the badge layout stable when absent (most benches). + const scopeAriaSuffix = scopeLabel ? ` (${scopeLabel})` : ""; + const scopeSvg = scopeLabel + ? `${escapeXml(scopeLabel.toUpperCase())}` + : ""; + // Bump the SVG canvas height when a scope label is rendered so the + // subscript doesn't clip outside the box on stricter image renderers. + const svgH = scopeLabel ? H + 8 : H; + const svg = ` - - OpenChainBench. ${escapeXml(b.title)}. ${rankLabel}, ${value} ${suffix} - - - + + OpenChainBench. ${escapeXml(b.title)}. ${rankLabel}${scopeAriaSuffix}, ${value} ${suffix} + + + ${ocbBrand} ${rankLabel} ${escapeXml(title)} ${value} ${suffix} + ${scopeSvg} `; return new NextResponse(svg, { headers: { "Content-Type": "image/svg+xml; charset=utf-8", - "Cache-Control": "public, max-age=300, s-maxage=300, stale-while-revalidate=600", + // Cache key varies by query string (chain), so different scopes get + // their own CDN entries instead of cross-poisoning each other. + Vary: "Accept, Accept-Encoding", + "Cache-Control": + "public, max-age=300, s-maxage=300, stale-while-revalidate=600", }, }); } diff --git a/src/app/api/cron/health-check/route.ts b/src/app/api/cron/health-check/route.ts index 0c5d2c48..7892bede 100644 --- a/src/app/api/cron/health-check/route.ts +++ b/src/app/api/cron/health-check/route.ts @@ -1,7 +1,7 @@ import { timingSafeEqual } from "node:crypto"; import { NextResponse, type NextRequest } from "next/server"; import { getBenchmarkSlugs } from "@/data/benchmarks"; -import { getSpecs } from "@/lib/spec"; +import { getSpecs, loadAllBenchmarks } from "@/lib/spec"; import { extractMetricName, Prometheus } from "@/lib/prometheus"; export const runtime = "nodejs"; @@ -214,12 +214,28 @@ export async function GET(req: NextRequest) { } } + // Pre-warm the per-bench unstable_cache + KV snapshot layer. This + // single call is what spares an unlucky cold-start user a draft render + // when Prom is slow: it keeps each bench's runtime-cache entry fresh + // and triggers a writeSnapshot to KV so the snapshot fallback always + // has a recent good value to surface. Failures are caught so a Prom + // hiccup here doesn't 500 the cron and lose the Slack alert. + let prewarmCount = 0; + let prewarmErr: string | undefined; + try { + const benches = await loadAllBenchmarks(); + prewarmCount = benches.length; + } catch (err) { + prewarmErr = err instanceof Error ? err.message : String(err); + } + return NextResponse.json({ checked: liveSpecs.length, transitions: transitions.length, sent: sent.length, dryRun: !webhook, transitionsList: transitions, + prewarm: { count: prewarmCount, err: prewarmErr }, }); } diff --git a/src/app/api/mcp/[transport]/route.ts b/src/app/api/mcp/[transport]/route.ts index a56fbcd6..4348ea36 100644 --- a/src/app/api/mcp/[transport]/route.ts +++ b/src/app/api/mcp/[transport]/route.ts @@ -35,7 +35,9 @@ const MAX_BODY_BYTES = 64 * 1024; // / instance-leak class of exfil attacks via the public MCP endpoint. // Derived from the metric names declared by current benchmark YAMLs. const QUERY_PROM_ALLOWED_METRIC_PREFIXES = [ + // Aggregator latency / head-lag "head_lag_seconds", + // Bridge benches (bridge-fee + bridge-quote-latency) "bridge_quote_latency_ms", "bridge_cost", "bridge_fees", @@ -44,12 +46,40 @@ const QUERY_PROM_ALLOWED_METRIC_PREFIXES = [ "bridge_output", "bridge_estimated_time", "bridge_quote_success", + // L1 finality + L2 block time "l1_finality_", + "l2_block_time_", + // Metadata + network + wallet coverage "metadata_coverage_", + "metadata_api_latency_", "networks_supported", "network_coverage_", - "perp_fees_", "wallet_labels_", + // Perp fees + buyback + oracle + validator yield + "perp_fees_", + "ocb_buyback_", + "ocb_oracle_", + "ocb_validator_", + "ocb_chain_", + // Gas oracle prediction accuracy + "gas_error_", + "gas_predicted_", + "gas_realized_", + "gas_oracle_", + // Stablecoin peg (+ usdt-anchored variant) + "peg_", + // Solana TX landing (observational + active) + "solana_landing_", + // Public RPC capabilities + "rpc_latency_", + "rpc_call_total", + "rpc_health", + "rpc_archive_depth_supported", + // Bridge revenue (Relay-style implied margin) + "relay_", + "per_swap_margin_usd", + // Hyperliquid frontends quality bench (bench № 030) + "hl_frontend_", ]; // PromQL identifiers that are NOT metric names - built-in functions, @@ -290,13 +320,22 @@ const mcpHandler = createMcpHandler( " • a derived metric (rates, ratios, deltas)", " • a histogram bucket aggregation across chains/regions", "", - "Allowed metric namespaces: head_lag_seconds, bridge_quote_latency_ms*,", - "bridge_cost*, bridge_fees*, bridge_fix_fee*, bridge_gas*, bridge_output*,", - "bridge_estimated_time*, bridge_quote_success, l1_finality_*,", - "metadata_coverage_*, network_coverage_*, networks_supported, perp_fees_*,", - "wallet_labels_*. Queries referencing other metrics (operational/internal", - "ones like `up`, `scrape_*`, `process_*`, `go_*`, `wallet_balance_*` or any", - "label-enumeration shape) are refused with `{error, reason}`.", + "Allowed metric namespaces (one prefix per OCB bench family):", + " head_lag_seconds (aggregator latency)", + " bridge_quote_latency_ms*, bridge_cost*, bridge_fees*, bridge_fix_fee*,", + " bridge_gas*, bridge_output*, bridge_estimated_time*, bridge_quote_success", + " l1_finality_*, l2_block_time_*", + " metadata_coverage_*, metadata_api_latency_*, network_coverage_*,", + " networks_supported, wallet_labels_*", + " perp_fees_*, ocb_buyback_*, ocb_oracle_*, ocb_validator_*, ocb_chain_*", + " gas_error_*, gas_predicted_*, gas_realized_*, gas_oracle_*", + " peg_* (stablecoin peg, both variants)", + " solana_landing_* (TX landing observational + active)", + " rpc_latency_*, rpc_call_total, rpc_health, rpc_archive_depth_supported", + " relay_*, per_swap_margin_usd (bridge revenue)", + "Queries referencing other metrics (operational/internal ones like `up`,", + "`scrape_*`, `process_*`, `go_*`, `wallet_balance_*` or any label-", + "enumeration shape) are refused with `{error, reason}`.", "", "Example: instant p50 over 1h for Mobula head-lag on Base:", " query_prom({", @@ -325,7 +364,7 @@ const mcpHandler = createMcpHandler( .max(604_800) .optional() .describe("If set, run a range query over the last N seconds (max 7 days = 604800). Omit for an instant query."), - steps: z.number().int().min(2).max(360).optional().describe("Number of samples for a range query (2–360). Default 60. Step duration = windowSec / steps."), + steps: z.number().int().min(2).max(360).optional().describe("Number of samples for a range query (2 to 360). Default 60. Step duration = windowSec / steps."), }, }, async ({ query, windowSec, steps }) => { diff --git a/src/app/apple-icon.tsx b/src/app/apple-icon.tsx index 77e95033..347c3042 100644 --- a/src/app/apple-icon.tsx +++ b/src/app/apple-icon.tsx @@ -26,7 +26,6 @@ export default function AppleIcon() { justifyContent: "center", }} > - {/* eslint-disable-next-line @next/next/no-img-element */} OpenChainBench ({ slug })); } -export default async function OG({ +// Emit one OG image per (slug, chain) combo so social shares of +// `/benchmarks/{slug}?chain=X` render a chain-honest card instead of the +// cross-chain aggregate leader (which is the misleading baseline-skew +// case bench-001 ran into: GMGN looking like "fastest crypto data API" +// on the unfiltered view because Solana's faster baseline drags the +// average down). The `id` Next.js threads through to the default image +// handler is the chain value; the unfiltered card uses `id="default"`. +export async function generateImageMetadata({ params, }: { params: Promise<{ slug: string }>; }) { const { slug } = await params; + // Load editorial-only via getBenchmark (memoised; safe at build time). const b = await getBenchmark(slug); + const chains = (b?.dimensions?.chain ?? []).filter( + (c) => c.value !== "all", + ); + return [ + { + id: "default", + alt, + size, + contentType, + }, + ...chains.map((c) => ({ + id: c.value, + alt: `${alt}. ${c.label}`, + size, + contentType, + })), + ]; +} + +export default async function OG({ + params, + id, +}: { + params: Promise<{ slug: string }>; + id: Promise; +}) { + const { slug } = await params; + const rawId = await id; + const chainId = typeof rawId === "string" && rawId !== "default" ? rawId : null; + // Fetch the chain-filtered bench when a chain id is present so the + // leader (and the headline sentence) reflect that chain's measured + // baseline. Falls back to the unfiltered fetch when the chain is + // missing or unknown — that mirrors the page-level behaviour. + const b = chainId + ? (await loadBenchmark(slug, { chain: chainId })) ?? (await getBenchmark(slug)) + : await getBenchmark(slug); if (!b) return new ImageResponse(

, { ...size }); const top = leader(b); const headline = top ? `${top.name} leads at ${fmtUnit(top.value, b.unit)}` : "Awaiting first run"; const sentence = headlineSentence(b); const catColor = CATEGORY_COLOR[b.category] ?? "#7a2e1f"; + const chainLabel = chainId + ? b.dimensions?.chain?.find((c) => c.value === chainId)?.label ?? chainId + : null; + const titleText = chainLabel ? `${b.title} on ${chainLabel}` : b.title; return new ImageResponse( ( @@ -72,18 +121,19 @@ export default async function OG({ }} > {b.metric} + {chainLabel ? ` · ${chainLabel}` : ""}
38 ? 76 : 92, + fontSize: titleText.length > 38 ? 76 : 92, fontWeight: 700, lineHeight: 0.98, letterSpacing: -2, maxWidth: 1080, }} > - {b.title} + {titleText}
; + searchParams?: Promise>; }): Promise { const { slug } = await params; - const b = await getBenchmark(slug); - if (!b) return {}; - const metaTitle = b.seoTitle ?? b.title; + // Next 16 ships searchParams as a Promise. Reading it here would normally + // tip the segment into "dynamic", but generateMetadata is allowed to + // consume request data without affecting the parent page's static + // rendering — the page.tsx body still resolves searchParams client-side + // through BenchmarkBody. + const sp = (await searchParams) ?? {}; + const rawChain = Array.isArray(sp.chain) ? sp.chain[0] : sp.chain; + // Always re-fetch unfiltered first — used for canonical fields and the + // default copy. When a chain is requested, fetch the filtered variant + // so headline sentence + template placeholders resolve against the + // chain-scoped leader rather than the cross-chain aggregate. + const baseBench = await getBenchmark(slug); + if (!baseBench) return {}; + const chainOption = (baseBench.dimensions?.chain ?? []).find( + (c) => c.value.toLowerCase() === (rawChain ?? "").toLowerCase(), + ); + const isChainScoped = Boolean(chainOption && chainOption.value !== "all"); + const filteredBench = isChainScoped + ? (await getBenchmark(slug, { chain: chainOption!.value })) ?? baseBench + : baseBench; + // Use the filtered bench for headline + template substitutions so the + // OG/Twitter card and meta description reference the chain-specific + // leader rather than the cross-chain aggregate (the headline of the + // unfiltered bench is misleading when a single chain dominates the + // baseline — e.g. Solana skewing the "fastest data API" claim on the + // Bench-001 aggregate view). + const b = filteredBench; + const chainLabel = chainOption?.label ?? null; + const baseTitle = b.seoTitle ?? b.title; + const metaTitle = isChainScoped && chainLabel + ? `${baseTitle} on ${chainLabel}` + : baseTitle; // Description precedence (most-to-least specific): // 1. `seo_description` from the YAML - hand-crafted snippet with the // long-tail query phrases we want to rank for. @@ -65,14 +97,32 @@ export async function generateMetadata({ // from the current leader's measured value. // 3. Just `subtitle` - when the bench has no live data yet. const sentence = headlineSentence(b); - const description = + let description = b.seoDescription ?? (sentence ? `${sentence} ${b.subtitle}` : b.subtitle); - const url = `${SITE.url}/benchmarks/${b.slug}`; + // Resolve any template placeholders ({{best_name}}, {{best_p50}}, ...) + // against the (possibly chain-scoped) bench so editorial copy in the + // description renders with live, chain-honest numbers. seoDescription + // is the most common host for these placeholders. + if (description) description = renderTemplate(description, b); + // Canonical NEVER carries `?chain=...`. Per-chain variants share the + // same canonical URL so Google consolidates link signal on the hub + // page instead of treating each tab as a separate document. The OG + // url is the chain-scoped one so social previews don't all collapse + // to the same target. + const canonical = `${SITE.url}/benchmarks/${baseBench.slug}`; + const ogUrl = isChainScoped + ? `${canonical}?chain=${chainOption!.value}` + : canonical; return { title: metaTitle, description, - alternates: { canonical: url }, - openGraph: { title: metaTitle, description, type: "article", url }, + alternates: { canonical }, + openGraph: { + title: metaTitle, + description, + type: "article", + url: ogUrl, + }, twitter: { card: "summary_large_image", title: metaTitle, description }, }; } @@ -121,7 +171,31 @@ export default async function BenchmarkPage({ ), getBenchmarks(), ]); - const variants: Record = Object.fromEntries(variantList); + // Variants only contribute chart / leaderboard / extras to the displayed + // bench (those legitimately differ per (chain, region) filter). Editorial + // copy (findings, faq, seoIntro, abstract, methodology) is the SAME on + // every tab and only resolves chain placeholders against the aggregate's + // bestPerChain/worstPerChain stash (computed unfiltered only), so we + // override these fields onto every variant. Without this, switching to + // a chain tab surfaces raw `{{best_name:chain:X}}` strings. + const variants: Record = Object.fromEntries( + variantList.map(([key, v]) => [ + key, + v === aggregate + ? v + : { + ...v, + findings: aggregate.findings, + faq: aggregate.faq, + seoIntro: aggregate.seoIntro, + abstract: aggregate.abstract, + methodology: aggregate.methodology, + perChainExplainer: aggregate.perChainExplainer, + bestPerChain: aggregate.bestPerChain, + worstPerChain: aggregate.worstPerChain, + }, + ]), + ); const benchmark = variants[variantKey(chain, region)] ?? aggregate; const isDraft = benchmark.status === "draft"; @@ -224,7 +298,7 @@ export default async function BenchmarkPage({ const faqJsonLd = buildFaqPageJsonLd(benchmark.faq, benchmarkUrl); return ( -
+