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/.gitignore b/.gitignore index ef02f9ad..a00e396f 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,4 @@ docs/*-cost-model.md # typescript *.tsbuildinfo next-env.d.ts +.claude/worktrees/ 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/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..6af3da32 --- /dev/null +++ b/benchmarks/network-fees.yml @@ -0,0 +1,345 @@ +# OpenChainBench. Bench № 031 + +slug: network-fees +number: "031" +title: Live blockchain transaction fee comparison across 20 L1 and L2 chains +seo_title: "Cheapest blockchain transaction fee 2026 in USD: Ethereum, Solana, BNB, Avalanche, TRON, Arbitrum, Optimism, Base, zkSync live" +seo_description: "Live USD cost of one native transaction on 20 chains. Layer 1: Ethereum, Solana, BNB, Avalanche, TRON, Cardano, Sui, TON, Stellar, Litecoin, Monero. Layer 2: Arbitrum, Optimism, Base, zkSync, Linea, Scroll, Blast, Mantle, Taiko. Slow, standard and fast tiers refreshed every 30 seconds." +subtitle: "Current USD cost of one native token transaction on 20 Layer 1 and Layer 2 chains, refreshed every 30 seconds." + +seo_intro: | + This page answers one question. How much does it cost in dollars to send one transaction on each major blockchain right now. We track 20 chains in parallel and refresh the number every 30 seconds. The eleven Layer 1 chains are Ethereum, Solana, BNB Chain, Avalanche, TRON, Cardano, Sui, TON, Stellar, Litecoin and Monero. The nine Layer 2 rollups are Arbitrum, Optimism, Base, zkSync Era, Linea, Scroll, Blast, Mantle and Taiko. For every chain we query its own fee market directly (eth_feeHistory for the EVM family, getRecentPrioritizationFees on Solana, koios epoch params on Cardano, fee_stats on Stellar, get_fee_estimate on Monero, the mempool oracle on Litecoin, getChainParameters on TRON, suix_getReferenceGasPrice on Sui), convert the result to the smallest native unit, then multiply by the live USD price of the chain's native token from Mobula. The output is the actual dollar amount a wallet user pays today. No gwei to lamport conversion, no marketing claim. Compare Ethereum gas now versus Solana fee in USD, see whether Arbitrum is still cheaper than Base today, find out which Layer 1 has the lowest transaction cost this minute. + +faq: + - q: "What does this benchmark measure?" + a: "The USD cost of one native token transaction on each of the 20 tracked chains, refreshed every 30 seconds. A native transaction is the simplest action on a chain. Send ETH on Ethereum, SOL on Solana, ADA on Cardano, XLM on Stellar, and so on. We do not yet measure ERC 20 transfers, DEX swaps or smart contract deployments. Those will ship as companion metrics in a later phase." + - q: "Which chains are tracked?" + a: "Eleven Layer 1 chains on the L1 tab. Ethereum, Solana, BNB Chain, Avalanche, TRON, Cardano, Sui, TON, Stellar, Litecoin and Monero. Nine Layer 2 rollups on the L2 tab. Arbitrum, Optimism, Base, zkSync Era, Linea, Scroll, Blast, Mantle and Taiko. The list matches the L1 finality and L2 block time benches so you can read cost and speed side by side." + - q: "Why USD instead of gas price in gwei?" + a: "Gas price in gwei on Ethereum cannot be compared to lamports per compute unit on Solana, stroops per operation on Stellar or sun per byte on TRON. The only honest cross chain unit is the dollar cost of a user facing action, computed at scrape time using a live USD price for each native token. Mobula's market API delivers the prices we multiply by." + - q: "What do slow, standard and fast tiers mean?" + a: "Tiers exist on chains with a priority market where users can pay more for faster inclusion. Slow targets the 25th percentile of recent priority bids, standard the 50th, fast the 90th. Chains with deterministic or near deterministic fees (Cardano, Stellar, 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 on non congested blocks." + - q: "Why is the Cardano fee always similar?" + a: "Cardano fees are deterministic. The protocol parameters min_fee_a per byte and min_fee_b base are set by governance and updated rarely. A standard ADA transaction is roughly 250 bytes, so the lovelace cost is essentially fixed until the next parameter vote. The USD figure on the leaderboard only moves because ADA's USD price moves." + - q: "Are the L2 numbers complete?" + a: "Not yet. The figure for each Layer 2 reflects L2 execution cost only (the wallet visible gas price times 21000 gas times the ETH price). The L1 data posting fee (blob market for EIP 4844 rollups like Arbitrum, Optimism and Base after Dencun, calldata for the rest) is a separate component that varies block to block and is currently excluded. A blended total cost figure will ship in a later phase. For now use the L1 view for true wallet cost comparison, and read the L2 view as the execution component only." + - q: "Which Layer 1 chain has the cheapest transaction fee right now?" + a: "Open the page. The leaderboard refreshes every 30 seconds and is sorted by cost. As a general pattern, Stellar, Avalanche, Litecoin, Solana, BNB Chain and TON cluster below one cent, Ethereum and Cardano around three to five cents, and TRON and Monero in the ten cent range. Sui sits in the low one cent range. Exact ordering depends on congestion and native token price at the moment of read." + - q: "How often does the page refresh?" + a: "Every 30 seconds. The harness re queries each chain's fee oracle and Mobula's price API on the same cadence, so headline values are at most 30 seconds stale plus chain RPC latency (typically under one second)." + - q: "Why are some chains showing one tier instead of three?" + a: "Cardano fees are protocol deterministic. Stellar's base fee is 100 stroops per operation network wide. TON's typical fee is a conservative observed value 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." + - q: "Can I cite a value from this page?" + a: "Yes. Every number is a Prometheus query over a 24h window. The query string is shown in the row's hover tooltip. The harness source is open at the link in the source field below. Cite the value and the timestamp at the top of the page." + +category: Blockchains +status: live +metric: Native transfer fee USD +unit: usd +higher_is_better: false + +abstract: | + Every 30 seconds we ask each of 20 chains for its current native + transaction fee in the chain's smallest unit (wei, lamport, lovelace, + stroop, sun, MIST, nanoton, litoshi, atomic), multiply by the live + USD price of the chain's native token from Mobula's market API and + publish the result. Chains with a priority market expose three tiers + (slow, standard, fast) mapped to roughly the 25th, 50th and 90th + percentile of the recent fee distribution. Deterministic fee chains + emit a single tier. The eleven Layer 1 chains mirror the L1 finality + bench so users can compare cost and speed side by side. The nine + Layer 2 rollups add the layer dimension so a wallet routing decision + can be made on real data instead of marketing claims. + +methodology: + - "Refresh cadence. 30 seconds. One process samples all 20 chains in parallel goroutines." + - "Ethereum, BNB Chain and Avalanche on the L1 tab. eth_feeHistory over the last 4 blocks at percentiles 25, 50 and 90. Cost = (base_fee + reward_percentile) * 21000 gas, mapped to slow, standard and fast." + - "Arbitrum, Optimism, Base, zkSync Era, Linea, Scroll, Blast, Mantle and Taiko on the L2 tab. Same eth_feeHistory flow against each rollup's sequencer RPC. ETH is the native gas asset on every tracked rollup." + - "Layer 2 caveat. The published figure is L2 execution cost only. The L1 data posting fee (blob market for EIP 4844 rollups, calldata for the rest) is excluded from this page and will be added as a separate blended figure in a later phase. On OP Stack rollups the L1 data fee can dominate the wallet visible total during expensive blob periods." + - "Solana. getRecentPrioritizationFees percentiles 25, 50 and 90 of micro lamports per compute unit, times 200 compute units, plus 5000 lamports base. Empty fees response collapses to a single standard tier at the 5000 base." + - "TRON. getChainParameters.getTransactionFee (currently 1000 sun per byte) times 268 bytes for a typical native transfer. Single tier because TRON native transfers do not bid into a priority market." + - "Cardano. koios epoch_params.min_fee_a and min_fee_b, times 250 bytes for a typical native transfer. Deterministic by protocol, refreshes only when on chain parameters change." + - "Stellar. horizon fee_stats.last_ledger_base_fee times 1 operation. Single tier." + - "Sui. suix_getReferenceGasPrice times 76000 gas (typical observed for a Coin::transfer call). Single standard tier." + - "TON. Hardcoded 0.005 TON, the typical observed wallet v4 transfer. TON's fee model uses Bag of Cells emulation and has no clean fee estimate RPC." + - "Litecoin. litecoinspace.org /api/v1/fees/recommended (hour, half hour and fastest fees in litoshi per vByte) times 225 vBytes for a typical 1 input 1 output P2WPKH transfer." + - "Monero. monero rpc get_fee_estimate.fees[0..2] times 1500 bytes for a typical 1 input 2 output RingCT transaction." + - "USD prices. api.mobula.io/api/1/market/multi-data polled every 30 seconds for all 20 native tokens in one call." + - "Failures. Any upstream error leaves the previous gauge in place, increments tx_fee_fetch_errors_total{chain, error_type}, and sets tx_fee_health{chain} to zero." + +findings: + - "{{best_name}} is the cheapest tracked native transaction at {{best_p50}} over the last 24 hours." + - "{{name:ethereum}} sits at {{p50:ethereum}} (standard tier, 24h median), the most expensive Layer 1 transaction on the leaderboard during normal congestion." + - "{{name:bnb}}, {{name:avalanche}}, {{name:stellar}}, {{name:solana}} and {{name:litecoin}} cluster near or below one cent for a standard transaction." + - "{{name:cardano}}, {{name:tron}} and {{name:monero}} use deterministic or near deterministic fee models. The USD figure on the leaderboard moves with native token price, not with network congestion." + - "USD costs are computed at scrape time. A 10 percent intraday move in the native token's USD price shifts the headline by 10 percent even if the chain native fee is flat. We surface that intentionally because the dollar cost is what a wallet user actually pays." + - "Layer 2 values currently capture L2 execution only. The L1 data posting component is excluded until the blended figure ships in a follow up phase." + +source: https://github.com/MobulaFi/mobula-monorepo/tree/main/miniapps/transaction-fee + +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 plus size times 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 × 76000 gas budget + formula: "USD cost of a native SUI transfer: live reference gas price × 76000 gas × 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 per 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 times 1500 bytes typical 1 input 2 output RingCT times 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 for now." + 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 for now." + queries: + p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="optimism",tier="std"}[24h]) + p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="optimism",tier="fast"}[24h]) + p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="optimism",tier="fast"}[24h]) + mean: avg_over_time(tx_fee_native_transfer_usd{chain="optimism",tier="std"}[24h]) + success: avg_over_time(tx_fee_health{chain="optimism"}[24h]) + series: tx_fee_native_transfer_usd{chain="optimism",tier="std"} + - slug: base + name: Base + layer: l2 + 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 for now." + 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 for now." + 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 for now." + 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 for now." + queries: + p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="scroll",tier="std"}[24h]) + p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="scroll",tier="fast"}[24h]) + p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="scroll",tier="fast"}[24h]) + mean: avg_over_time(tx_fee_native_transfer_usd{chain="scroll",tier="std"}[24h]) + success: avg_over_time(tx_fee_health{chain="scroll"}[24h]) + series: tx_fee_native_transfer_usd{chain="scroll",tier="std"} + - slug: blast + name: Blast + layer: l2 + tag: "OP Stack optimistic rollup Blast, 21000 gas" + formula: "Median USD cost of a native ETH transfer on Blast over 24h. L2 execution cost only, L1 data posting fee excluded for now." + queries: + p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="blast",tier="std"}[24h]) + p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="blast",tier="fast"}[24h]) + p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="blast",tier="fast"}[24h]) + mean: avg_over_time(tx_fee_native_transfer_usd{chain="blast",tier="std"}[24h]) + success: avg_over_time(tx_fee_health{chain="blast"}[24h]) + series: tx_fee_native_transfer_usd{chain="blast",tier="std"} + - slug: mantle + name: Mantle + layer: l2 + 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 for now." + 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 for now." + 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/pm-data-freshness.yml b/benchmarks/pm-data-freshness.yml new file mode 100644 index 00000000..37b6d8b9 --- /dev/null +++ b/benchmarks/pm-data-freshness.yml @@ -0,0 +1,122 @@ +# OpenChainBench. Bench № 032 + +slug: pm-data-freshness +number: "032" +title: Best prediction market data API by freshness +seo_title: "Fastest Polymarket data API in 2026: Mobula vs Codex vs Polymarket native WebSocket freshness benchmark" +seo_description: "Live freshness leaderboard for prediction market data APIs. How many milliseconds each provider lags Polymarket's own CLOB WebSocket gateway when relaying the same trade. Mobula, Codex, and Polymarket native compared every minute on a rotating basket of top volume markets." +subtitle: Per event delay between provider arrival and Polymarket gateway publish, measured every minute on the top markets by 24h volume. + +category: Aggregators +status: live +metric: Freshness delta vs Polymarket +unit: ms +higher_is_better: false + +seo_intro: | + Prediction markets generate the most time sensitive event stream in crypto. + An election market settles in seconds, a sports book moves on every play. + Builders that integrate Polymarket through a data provider rather than + hitting the CLOB directly need to know how many milliseconds that + provider adds between when Polymarket itself publishes a trade and when + the provider relays the same trade to its WebSocket subscribers. This + benchmark measures exactly that. The harness subscribes to the same + basket of top volume Polymarket markets on Polymarket's own CLOB + WebSocket (the canonical source, T0), on Mobula's PM WebSocket, and on + Codex GraphQL subscriptions. Each trade is cross correlated by + condition id and price across the three streams, and the per provider + lag versus Polymarket's gateway publish time is recorded as a + Prometheus histogram. The leaderboard sorts by p50 freshness delta in + milliseconds, lower is better. + +abstract: | + Three WebSocket subscribers ride the same rotating basket of ~20 top + volume Polymarket markets simultaneously. For every trade event + published on Polymarket's own CLOB WebSocket gateway (the canonical + T0), we record the moment it lands and the moment each provider + relays the same trade. The signature used to match a trade across + providers is the tuple (conditionId, priceUSD rounded to 3 decimals, + trade size, 5 second time bucket), which is robust against the minor + clock skew between gateways. Providers that fail to relay a trade + within 90 seconds are not counted toward their p50, only toward their + receive total, so a provider can look fresh on the leaderboard while + silently dropping events. The success rate column flags that. + +methodology: + - "Polymarket CLOB WebSocket is the canonical T0. `wss://ws-subscriptions-clob.polymarket.com/ws/market` is public, no auth, sub 50ms gateway publish latency from EU West." + - "Mobula PM WebSocket. `wss://pm-api-prod-eu.mobula.io`. Auth via API key in the subscribe payload. Cloudflare on the gateway requires a browser User Agent on the upgrade request, default Go HTTP UA is silently filtered." + - "Codex GraphQL subscriptions. `wss://graph.codex.io/graphql` with the `graphql-transport-ws` subprotocol. Firehose `onPredictionTradesCreated` subscription filtered client side to the Polymarket protocol marketIds in our basket." + - "Basket: top 20 active Polymarket markets by 24h volume, refreshed every 5 minutes from `gamma-api.polymarket.com`. Each market contributes two clobTokenIds (Yes and No outcomes)." + - "Cross correlation key: (conditionId, priceUSD × 1000 rounded, sizeUSD × 1_000_000 rounded, floor(trade_time / 5s)). The 5 second bucket absorbs clock skew without merging unrelated trades." + - "Histogram buckets: 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000, 60000 ms. Polymarket's own arrivals always land in the smallest bucket because by construction we measure other providers against it." + - "Region: `eu-west` (Railway europe-west4). Polymarket gateway is geographically distributed; deltas reflect EU client to EU gateway latency." + +findings: + - "Polymarket's own CLOB WebSocket is by definition the freshest source on the leaderboard. The lag versus its own gateway publish time is on the order of the network round trip from the harness to the gateway, typically below 100 ms p50 from EU West." + - "{{name:mobula}} relays Polymarket trades with a p50 delta of {{p50:mobula}} versus Polymarket native gateway. Mobula's PM WebSocket runs on a dedicated edge service in EU West and forwards events without orderbook reconstruction, which is why the gap to native is small." + - "{{name:codex}} sits at p50 {{p50:codex}} because Codex indexes the chain event (Polygon block confirmation) rather than the off chain orderbook publish. The lag includes Polygon block time (~2s) plus ingestion." + - "The spread between providers reflects integration depth: native gateway vs edge cached relay vs chain indexed pipeline. None of these is wrong, they answer different questions. For live trading UIs the gateway path is the only viable one." + +faq: + - q: "Which Polymarket data API has the lowest latency right now?" + a: "{{best_name}} currently leads at {{best_p50}} (p50 over the last 24h) measured as time from Polymarket's own CLOB WebSocket publish to provider relay. The leaderboard re sorts every minute on fresh Prometheus samples, so the answer reflects the actual measured lag on the active market basket, not a marketing claim." + - q: "What does 'freshness delta' mean for a prediction market API?" + a: "We connect to Polymarket's own CLOB WebSocket and to the provider's WebSocket simultaneously, subscribe to the same markets, and for every trade event we record how many milliseconds the provider takes to relay the event after Polymarket itself publishes it. Lower is better. Polymarket's own gateway publish time is the canonical T0 because by construction nothing downstream can be faster than the source." + - q: "Is Mobula's PM WebSocket faster than Codex?" + a: "It depends on what each provider does under the hood. {{name:mobula}} is an edge cached relay of Polymarket's own gateway, so the p50 delta is roughly the network round trip between the two gateways plus a few ms of bookkeeping. {{name:codex}} ingests the on chain confirmation on Polygon, which adds the block time (~2 seconds) before any trade can be relayed. For a live UI building on Polymarket, the relay path wins on freshness. For on chain reconciliation or settlement workflows, the chain indexed path is what you actually want. They answer different questions." + - q: "Why don't you include Polymarket REST polling on this benchmark?" + a: "Freshness is a WebSocket question. REST polling at 1s would have a floor freshness around 500ms (poll interval / 2) plus RTT, dominated by how often you poll. The Polymarket gateway WebSocket exists for exactly this reason, to avoid that floor. Adding REST as a row would make the leaderboard noisy without changing the conclusion: WebSocket beats polling by definition for real time data." + - q: "Are these numbers comparable to Kalshi or Limitless?" + a: "Not directly. This benchmark measures Polymarket as the underlying venue, since it's the largest and the one most providers proxy. Kalshi and Limitless are separate exchanges with separate data feeds, and providers that cover them often have a different ingestion path. We may add a Kalshi specific tab in a later phase. For now, treat the leaderboard as 'how fresh is your Polymarket data feed'." + - q: "How does OpenChainBench measure freshness?" + a: "Three WebSocket clients run in parallel inside the harness, all subscribed to the same basket of top volume Polymarket markets. Every minute we refresh the basket from `gamma-api.polymarket.com`. For each trade event, we compute a signature `(conditionId, price rounded to 3 decimals, size in micros, 5 second time bucket)` and record the wall clock receive time on each provider. The freshness delta is `recv_provider - recv_polymarket` for the same signature. We export the histogram to Prometheus, the leaderboard reads the 24h p50." + +source: https://github.com/MobulaFi/mobula-monorepo/tree/main/miniapps/pm-freshness-bench + +prometheus: + window: 24h + expected_freshness_seconds: 300 + +providers: + - slug: polymarket + name: Polymarket + tag: Native gateway WebSocket, no auth, sub 50ms publish latency + formula: "Polymarket gateway publish time is the canonical T0. The near zero row is the network round trip from the harness to the gateway, not a comparison against another source." + queries: + # Polymarket is T0 by construction. Delta vs itself is zero. Use a + # sub millisecond floor (0.5 ms) so the row and the chart line are + # visible on a linear y axis next to the multi second Codex value. + # Pure vector(0) gets clipped to the axis baseline and disappears. + p50: vector(0.5) + p90: vector(0.5) + p99: vector(0.5) + mean: vector(0.5) + success: avg_over_time(pm_health{provider="polymarket"}[24h]) + sample_size: sum(increase(pm_events_total{provider="polymarket"}[24h])) + series: vector(0.5) + + - slug: mobula + name: Mobula + tag: Edge cached Polymarket relay, browser UA required + formula: "Median ms lag versus Polymarket CLOB gateway, measured by cross correlating trades by (conditionId, price, size, 5s bucket) over the rotating basket of top 20 markets." + queries: + p50: histogram_quantile(0.50, sum(rate(pm_freshness_delta_ms_bucket{provider="mobula",kind="trade"}[24h])) by (le)) + p90: histogram_quantile(0.90, sum(rate(pm_freshness_delta_ms_bucket{provider="mobula",kind="trade"}[24h])) by (le)) + p99: histogram_quantile(0.99, sum(rate(pm_freshness_delta_ms_bucket{provider="mobula",kind="trade"}[24h])) by (le)) + mean: sum(rate(pm_freshness_delta_ms_sum{provider="mobula",kind="trade"}[24h])) / sum(rate(pm_freshness_delta_ms_count{provider="mobula",kind="trade"}[24h])) + success: clamp_max(sum(rate(pm_matched_total{provider="mobula",kind="trade"}[24h])) / sum(rate(pm_events_total{provider="polymarket",kind="trade"}[24h])), 1) + sample_size: sum(increase(pm_matched_total{provider="mobula",kind="trade"}[24h])) + series: histogram_quantile(0.50, sum(rate(pm_freshness_delta_ms_bucket{provider="mobula",kind="trade"}[1h])) by (le)) + + - slug: codex + name: Codex + tag: On chain Polygon indexer, ~2s block time floor + formula: "Median ms lag versus Polymarket CLOB gateway. Codex indexes the on chain Polygon settlement of each trade, so the lag includes block time (~2s) plus ingestion." + queries: + p50: histogram_quantile(0.50, sum(rate(pm_freshness_delta_ms_bucket{provider="codex",kind="trade"}[24h])) by (le)) + p90: histogram_quantile(0.90, sum(rate(pm_freshness_delta_ms_bucket{provider="codex",kind="trade"}[24h])) by (le)) + p99: histogram_quantile(0.99, sum(rate(pm_freshness_delta_ms_bucket{provider="codex",kind="trade"}[24h])) by (le)) + mean: sum(rate(pm_freshness_delta_ms_sum{provider="codex",kind="trade"}[24h])) / sum(rate(pm_freshness_delta_ms_count{provider="codex",kind="trade"}[24h])) + success: clamp_max(sum(rate(pm_matched_total{provider="codex",kind="trade"}[24h])) / sum(rate(pm_events_total{provider="polymarket",kind="trade"}[24h])), 1) + sample_size: sum(increase(pm_matched_total{provider="codex",kind="trade"}[24h])) + series: histogram_quantile(0.50, sum(rate(pm_freshness_delta_ms_bucket{provider="codex",kind="trade"}[1h])) by (le)) 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-tx-landing-latency.yml b/benchmarks/solana-tx-landing-latency.yml deleted file mode 100644 index 309c8112..00000000 --- a/benchmarks/solana-tx-landing-latency.yml +++ /dev/null @@ -1,208 +0,0 @@ -# OpenChainBench. Bench № 027 - -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." -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 -metric: p50 slot delta to confirmed (7-day window) -unit: slots -higher_is_better: false - -disclaimer: | - Six caveats. (1) us-east only, sgp / eu-west arrive in V2. (2) One pre-registered tip per service. (3) Synthetic payload (1-lamport + memo); real swaps may land differently. (4) Helius / Astralane / Nozomi fan out to Jito internally; Jito control probe runs each cycle. (5) Confirmation = `confirmed`. (6) Slot delta is canonical; ms is derived (≈ slot_delta × 400 ms + RTT). Pair with /benchmarks/solana-tx-landing. - -seo_intro: | - This benchmark answers the only question that matters to a - Solana trader picking a landing service. how many slots does - your signed mainnet transaction take to reach the confirmed - state on chain. Every hour from a us-east probe, the harness - submits an identical signed tx through each of 5 services in - parallel, captures the submit slot before send and the land - slot from the signatureSubscribe WebSocket notification at - commitment=confirmed, and increments per-service Prometheus - histograms. Headline numbers shown are p50 and p99 slot delta - over a rolling 7-day window. Wall-clock milliseconds are - published alongside for intuition (one Solana slot is ~400 ms, - so a p50 of 1 slot is ~400 ms wall-clock plus submission RTT) - but slot delta is the canonical, sponsor-proof on-chain - measurement. - Why slot delta is the right metric. Solana confirmation is a - slot-level event. when a slot reaches supermajority vote, every - transaction in it becomes confirmed simultaneously. Wall-clock - ms conflates HTTP submission time, our RPC's polling lag, and - network RTT to the public WebSocket - all of which are - measurement artifacts unrelated to the landing service's actual - routing quality. Slot delta is what the chain itself records. - Coverage. 5 services probed in V0-Lean. Jito Block Engine (the - control / baseline because Helius, Astralane, Nozomi all - internally route some flow through it). Helius Sender in - `swqos_only=true` mode (isolates the Helius own-path from the - Jito leg). Astralane Iris (tip-refund mechanism). Nozomi by - Temporal Labs (premium tier, hard 1M lamport tip floor). - 0slot.trade (premium tier). NextBlock, bloXroute and - SolanaVibeStation arrive in the next tier (V1) once the first - sponsors land. Companion bench. /benchmarks/solana-tx-landing - measures market share via on-chain tip-wallet attribution - - who carries the flow today, regardless of speed. - -abstract: | - We probe 5 Solana transaction landing services from a single - Railway us-east region, once per hour, by submitting an - identical signed mainnet transaction to each. The payload is - the minimal valid Solana tx, compute-budget instructions - (50k CU limit, 50k micro-lamport/CU price), a 1-lamport - self-transfer, the per-service tip transfer to the service's - documented tip wallet, and an OCB-prefixed memo for forensic - traceability. All five services are submitted in parallel - goroutines within a single cycle so they sample the same chain - congestion window. The headline measurement is slot delta, - land_slot minus submit_slot, captured from the - signatureSubscribe WebSocket notification's context.slot field - at commitment=confirmed. Wall-clock ms is reported alongside - but is a derived approximation, slot_delta × ~400 ms plus - submission RTT and goroutine startup variance. A 60 s no- - confirmation deadline classifies the probe as - dropped{reason=timeout}; structured RPC errors classify as - invalid; transport failures as network_error; HTTP 419 / 429 / - "rate limit" errors classify as rate_limited (a separate label - so quota issues don't bias the bench against the throttled - service). Cost. ~$159/mo at SOL=$86, 86 % of which goes to the - four ≥1M-lamport-floor services (Nozomi, 0slot, bloXroute, - NextBlock, only two of these in V0-Lean). Sponsor SOL credits - covering a service's own probes are explicitly allowed per the - sponsor-proof framework. Limitations. (a) Single us-east - region, sgp / eu-west arrive in V2 once sponsors fund - geographic-edge story. (b) 1-hour cadence, 168 probes per - service per 7-day window, enough for stable p50 / p99 over the - publication window, not enough for intra-hour resolution - (V0.5 / V1 upgrade if needed). (c) Fan-out, Helius probed in - `swqos_only` mode only in V0-Lean to keep wire shape simple; - dual-mode arrives in v1.0.1 methodology PR. - -methodology: - - "Source endpoints (us-east Railway, base64 JSON-RPC sendTransaction unless noted). Jito `ny.mainnet.block-engine.jito.wtf/api/v1/transactions`. Helius Sender `ewr-sender.helius-rpc.com/fast?swqos_only=true` (skipPreflight + maxRetries=0). Nozomi `http://edge.nozomi.temporal.xyz/api/sendBatch?c=` (binary `[u16_BE_len][tx_bytes]`, HTTP per Temporal Labs). Astralane `ny.gateway.astralane.io/iris?api-key=` (3-elem params, mevProtect). 0slot `ny.0slot.trade?api-key=`." - - "Probe payload. 5 instructions in this exact order: SetComputeUnitLimit(50,000) + SetComputeUnitPrice(50,000 micro-lamports/CU) + SystemProgram.Transfer(payer→payer, 1 lamport) + SystemProgram.Transfer(payer→service tip wallet, floor lamports) + Memo(`ocb---`). cycle_id is an 8-byte random hex shared across the five parallel probes of one cycle, so the on-chain memos correlate." - - "Tip floors (pre-registered, methodology PR + 14-day window to change). Jito 10,000 lamports. Helius Sender 10,000. Astralane 500,000 net of refunds. Nozomi 1,000,000. 0slot 1,000,000." - - "Submission flow. One getLatestBlockhash(processed) shared across all five probes. One getSlot(processed) as submit_slot. For each service we subscribe to the signature via signatureSubscribe on the public WS BEFORE submission (otherwise a fast confirm could fire before we listen). Probes then fire in parallel goroutines, sign, POST. We block on the signatureNotification at commitment=confirmed; context.slot is land_slot; slot_delta = land_slot - submit_slot." - - "Why slot delta is canonical. Solana confirmation is slot-level. when a slot reaches supermajority, every tx in it becomes confirmed simultaneously. The WS pushes notifications for all subscribed sigs in that slot at the same instant. So sub-400 ms wallclock diffs between services in the same slot are artifacts (goroutine startup, RTT), not routing quality. slot_delta is what the chain records, what to cite in audits." - - "Wall-clock ms is a derived approximation. ms ≈ slot_delta × ~400 ms + HTTP submission RTT + variance. We publish it for intuition because traders think in seconds, not slots, but it should not be the sole metric in a sponsor pitch or audit. If a service argues 'your ms numbers are biased by your RTT', the answer is the slot delta column, which is RTT-independent." - - "Drop classification. timeout = no confirmation within 60 s. invalid = RPC error, on-chain Err, or BlockhashNotFound. network_error = transport-level (timeout, DNS, EOF, connection refused). rate_limited = HTTP 419 / 429 / 'rate limit' / 'too many requests'. landing_rate is published as success / (success + timeout), rate_limited and network_error are excluded so quota / transport issues don't bias the bench against a throttled service." - - "Jito control probe. Helius (default), Astralane, Nozomi route a portion of flow through Jito internally, conflating own-path vs Jito-caught-it. Jito is in the V0-Lean set so its control fires in the same cycle. Same slot_delta as Jito = no measurable own-path value. Suspect ahead by 1+ slot = real routing advantage." - - "Reproducibility. The full harness source is at github.com/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." - -findings: - - "{{best_name}} leads the V0-Lean probe set at p50 = {{best_p50}} slot delta over the rolling 7-day window. Lower = fewer Solana slots between submission and confirmation. The gap between fastest and slowest is the operational signal, every service claims '99 %+ landing rate' in marketing copy, but the chain doesn't lie about which slot included your tx. A 1-slot difference is ~400 ms — enough for a MEV bot to front-run a competitor." - - "{{name:jito}} is the baseline / control. Helius (default mode), Astralane, and Nozomi all internally fan out to Jito, so the Jito p50 is the floor any premium service must beat. Same slot_delta as Jito on a given cycle = the service is essentially using Jito as its inclusion path. {{name:jito}} sits at p50 = {{p50:jito}} slot delta." - - "{{name:helius-sender}} in `swqos_only` mode isolates Helius's own routing path from the Jito leg. p50 = {{p50:helius-sender}} slot delta. A v1.0.1 methodology update will publish Helius default mode (with Jito fan-out) side-by-side for direct comparison." - - "{{name:nozomi}} premium pricing (1M lamport hard floor, ~10 × Jito's competitive level) only makes economic sense if the slot_delta advantage is meaningful. p50 = {{p50:nozomi}} slot delta. The gap vs Jito quantifies whether the tip premium buys real slot priority." - - "{{worst_name}} trails at p50 = {{worst_p50}} slot delta. The worst slot delta in the V0-Lean set is not necessarily a bad service, it may be a service whose strength is in dimensions this bench doesn't measure (anti-MEV protection, durable nonce, fee-refund mechanics). Latency is one variable, not the whole product." - -faq: - - q: "Why is slot delta the headline metric instead of wall-clock latency?" - a: "Solana confirmation is a slot-level event. when a slot reaches supermajority vote (~2/3 of stake), every transaction in that slot becomes confirmed simultaneously. The WebSocket pushes notifications for all subscribed signatures in that slot at the same instant. So if 3 services delivered txs that all landed in the same slot, our wallclock measurement records the same time for all 3, the only differentiation is whether the next service's tx landed in slot N or N+1. slot_delta captures that directly. Wall-clock ms is derived (slot_delta × ~400 ms + RTT + variance) and conflates routing quality with measurement artifacts like HTTP submission speed and our public RPC's network latency. We publish wall-clock ms because traders think in seconds, but slot_delta is what you should cite in an audit or methodology dispute. It's RTT-independent and reads directly from the chain." - - q: "What does '1 slot' actually mean in time?" - a: "Solana slots are ~400 ms in practice (~625 ms target with leader skips and forks averaging it down). A p50 slot_delta of 1 means your tx typically lands in the slot immediately following your submission, ~400 ms after sendTransaction return. p50 of 2 means typically one slot later, ~800 ms. The gap between p50 = 1 and p50 = 2 is the operational signal, a service that consistently lands 1 slot earlier than its competitors is ~400 ms ahead, which is the difference between catching an arbitrage and missing it." - - q: "Why an active bench when /benchmarks/solana-tx-landing already exists?" - a: "/benchmarks/solana-tx-landing is observational, it watches the chain and counts who carries the flow. It cannot answer 'how fast does my tx land if I send it now', because it doesn't send anything. This bench (active probing) answers that, at the cost of running 24 / 7 with real SOL ($159 / month at the V0-Lean cadence). The two benches answer different product questions. Read both." - - q: "Why only 5 services, not the 8 you measure observationally?" - a: "NextBlock, bloXroute Trader, and SolanaVibeStation all require paid plans or sales-call onboarding before they issue an API key. We're shipping V0-Lean today with the 5 services that have a clear self-serve or contact-based path. The other 3 will be added as the bench scales. The observational bench at /benchmarks/solana-tx-landing already covers all 8 because it doesn't need API keys." - - q: "Why us-east only?" - a: "V0-Lean. us-east is the de-facto Solana baseline (Jito, NextBlock, bloXroute, Helius all anchor their best-connected POPs there) and is where most Solana bots deploy by default. Adding eu-west and sgp triples the bench cost and answers a different question ('does the ranking change by geography?'), which is a planned V2 scope expansion." - - q: "What's the probe payload?" - a: "Five instructions in this exact order, locked by methodology §3. (1) SetComputeUnitLimit(50,000). (2) SetComputeUnitPrice(50,000 micro-lamports/CU), together a 2,500-lamport priority fee. (3) SystemProgram.Transfer of 1 lamport from the prober keypair to itself, the minimal valid state-touching tx. (4) SystemProgram.Transfer to the service's documented tip wallet at the pre-registered floor. (5) Memo program write with the cycle ID, service name, and probe mode. Total weight: ~600 bytes, well under the 1,232-byte tx limit." - - q: "How is fan-out handled?" - a: "Helius (default mode), Astralane, and Nozomi route a portion of flow through Jito internally. The Jito control probe, Jito is part of the V0-Lean probe set, fires in the same cycle as the suspect services with the same blockhash and a comparable tip. The slot_delta column tells you immediately whether a suspect service is adding value beyond a Jito wrapper. Same slot_delta as Jito = same inclusion slot = Jito caught it. Suspect ahead by 1+ slot = real own-path routing. Helius is additionally probed in `?swqos_only=true` mode to fully isolate its own routing path." - - q: "Can a service detect and prioritise our probes?" - a: "Yes, in principle. The memo prefix `ocb-` is deterministic and the keypair is constant per region. Anti-fingerprinting (memo randomisation, sub-account rotation, tip jitter within the floor band) ships in v1.0.2 methodology PR. We disclose this risk openly; the trade-off is that announcing the bench publicly to providers gives them a chance to fix real performance issues before we publish, which is a good outcome. We do NOT accept private deals to alter the probe surface for any specific service." - - q: "Why is sample_size on the dashboard ~168 per service?" - a: "V0-Lean cadence = 1 probe per service per hour from 1 region. 168 = 24 hours × 7 days. The 7-day publication window is the trade-off between statistical resolution (sample size grows with window) and freshness (shorter window reflects current chain conditions). At ~168 samples per cell, p50 is stable to within ±5 % and p99 to within ±15 %. Lower confidence intervals are unlocked at V0.5 cadence (1 / 10 min, ~$760 / mo) and above." - - q: "How is the confirmation observed?" - a: "Via `signatureSubscribe` on the public mainnet WebSocket (`wss://api.mainnet-beta.solana.com`). The subscription is registered BEFORE submission so a fast-confirming tx cannot complete before we are listening (otherwise we'd miss the notification and timeout spuriously). The RPC pushes the notification at the instant the commitment level is reached, so observation resolution is RTT-bounded (~30-50 ms us-east → mainnet-beta) and slot_delta is read directly from the notification's context.slot field. HTTP polling at 200 ms is an automatic fallback if the WebSocket fails to connect for a given cycle." - -source: https://github.com/OpenChainBench/OpenChainBench/tree/main/harnesses/solana-tx-landing - -prometheus: - window: 7d - expected_freshness_seconds: 7200 - -# Real metrics emitted by the active prober in solana-tx-landing harness: -# solana_landing_probe_success_total{service, mode, region} counter -# solana_landing_probe_dropped_total{service, mode, region, reason} counter -# solana_landing_probe_latency_ms{service, mode, region} gauge (set every cycle) -# solana_landing_probe_latency_slots{service, mode, region} gauge (set every cycle) -# solana_landing_probe_latency_slots_histogram{service, mode, region} histogram (debug) -# solana_landing_probe_latency_ms_histogram{service, mode, region} histogram (debug) -# solana_landing_probe_keypair_balance_sol{region} gauge -# solana_landing_probe_cycle_total{region} counter -# solana_landing_probe_enabled{region} gauge -# -# Headline metric (canonical) = slot_p50 / slot_p99 read from the gauge. -# Wall-clock ms is published alongside via the standard p50/p90/p99 fields -# for reader intuition but is derived (slot_delta × ~400 ms + RTT + variance). -# Mode label is `swqos_only` for helius-sender, `default` for the rest. -# -# Why quantile_over_time(gauge) instead of histogram_quantile(histogram)? -# At V0-Lean cadence (1 probe / hour) we have ~168 samples per cell over 7d. -# Histogram buckets {100, 250, 500, 1000, 2000, 5000, 10000, 30000, 60000} ms -# have ~3 buckets in the 1-5s zone where probes actually land, so -# histogram_quantile collapses to bucket midpoints (1500, 3500 ms) and the -# series looks flat. quantile_over_time on the gauge takes the real sample -# at the 50th percentile, which is the accurate published number. - -providers: - - slug: jito - name: Jito Block Engine - tag: Baseline + control probe; atomic bundles + tip auction since 2022 - formula: "50th percentile over 7d of slot delta (land_slot − submit_slot) for hourly signed mainnet probes submitted to Jito's `ny.mainnet.block-engine.jito.wtf` from us-east." - queries: - p50: quantile_over_time(0.5, solana_landing_probe_latency_slots{service="jito",region="us-east"}[7d]) - p90: quantile_over_time(0.9, solana_landing_probe_latency_slots{service="jito",region="us-east"}[7d]) - p99: quantile_over_time(0.99, solana_landing_probe_latency_slots{service="jito",region="us-east"}[7d]) - mean: avg_over_time(solana_landing_probe_latency_slots{service="jito",region="us-east"}[7d]) - success: sum(rate(solana_landing_probe_success_total{service="jito",region="us-east"}[7d])) / (sum(rate(solana_landing_probe_success_total{service="jito",region="us-east"}[7d])) + sum(rate(solana_landing_probe_dropped_total{service="jito",region="us-east",reason="timeout"}[7d]))) - sample_size: sum(increase(solana_landing_probe_success_total{service="jito",region="us-east"}[7d])) - series: solana_landing_probe_latency_slots{service="jito",region="us-east"} - - - slug: helius-sender - name: Helius Sender (swqos_only) - tag: Isolated Helius own-path (no Jito fan-out); anycast + 7 POPs - formula: "50th percentile over 7d of slot delta (land_slot − submit_slot) for hourly signed probes submitted to Helius Sender in `swqos_only=true` mode from us-east, isolating its own-path." - queries: - p50: quantile_over_time(0.5, solana_landing_probe_latency_slots{service="helius-sender",mode="swqos_only",region="us-east"}[7d]) - p90: quantile_over_time(0.9, solana_landing_probe_latency_slots{service="helius-sender",mode="swqos_only",region="us-east"}[7d]) - p99: quantile_over_time(0.99, solana_landing_probe_latency_slots{service="helius-sender",mode="swqos_only",region="us-east"}[7d]) - mean: avg_over_time(solana_landing_probe_latency_slots{service="helius-sender",mode="swqos_only",region="us-east"}[7d]) - success: sum(rate(solana_landing_probe_success_total{service="helius-sender",mode="swqos_only",region="us-east"}[7d])) / (sum(rate(solana_landing_probe_success_total{service="helius-sender",mode="swqos_only",region="us-east"}[7d])) + sum(rate(solana_landing_probe_dropped_total{service="helius-sender",mode="swqos_only",region="us-east",reason="timeout"}[7d]))) - sample_size: sum(increase(solana_landing_probe_success_total{service="helius-sender",mode="swqos_only",region="us-east"}[7d])) - series: solana_landing_probe_latency_slots{service="helius-sender",mode="swqos_only",region="us-east"} - - - slug: astralane - name: Astralane Iris - tag: Tip-refund mechanism, sendBundle / sendIdeal modes, FRA + NY POPs - formula: "50th percentile over 7d of slot delta (land_slot − submit_slot) for hourly signed probes submitted with a 500k-lamport net tip to Astralane Iris's NY gateway from us-east." - queries: - p50: quantile_over_time(0.5, solana_landing_probe_latency_slots{service="astralane",region="us-east"}[7d]) - p90: quantile_over_time(0.9, solana_landing_probe_latency_slots{service="astralane",region="us-east"}[7d]) - p99: quantile_over_time(0.99, solana_landing_probe_latency_slots{service="astralane",region="us-east"}[7d]) - mean: avg_over_time(solana_landing_probe_latency_slots{service="astralane",region="us-east"}[7d]) - success: sum(rate(solana_landing_probe_success_total{service="astralane",region="us-east"}[7d])) / (sum(rate(solana_landing_probe_success_total{service="astralane",region="us-east"}[7d])) + sum(rate(solana_landing_probe_dropped_total{service="astralane",region="us-east",reason="timeout"}[7d]))) - sample_size: sum(increase(solana_landing_probe_success_total{service="astralane",region="us-east"}[7d])) - series: solana_landing_probe_latency_slots{service="astralane",region="us-east"} - - - slug: nozomi - name: Nozomi - tag: Temporal Labs, direct-to-leader, premium 1M-lamport hard floor - formula: "50th percentile over 7d of slot delta (land_slot − submit_slot) for hourly signed probes submitted with a 1M-lamport tip to Nozomi's `edge.nozomi.temporal.xyz` from us-east." - queries: - p50: quantile_over_time(0.5, solana_landing_probe_latency_slots{service="nozomi",region="us-east"}[7d]) - p90: quantile_over_time(0.9, solana_landing_probe_latency_slots{service="nozomi",region="us-east"}[7d]) - p99: quantile_over_time(0.99, solana_landing_probe_latency_slots{service="nozomi",region="us-east"}[7d]) - mean: avg_over_time(solana_landing_probe_latency_slots{service="nozomi",region="us-east"}[7d]) - success: sum(rate(solana_landing_probe_success_total{service="nozomi",region="us-east"}[7d])) / (sum(rate(solana_landing_probe_success_total{service="nozomi",region="us-east"}[7d])) + sum(rate(solana_landing_probe_dropped_total{service="nozomi",region="us-east",reason="timeout"}[7d]))) - sample_size: sum(increase(solana_landing_probe_success_total{service="nozomi",region="us-east"}[7d])) - series: solana_landing_probe_latency_slots{service="nozomi",region="us-east"} diff --git a/benchmarks/solana-tx-landing.yml b/benchmarks/solana-tx-landing.yml deleted file mode 100644 index 19fbbac0..00000000 --- a/benchmarks/solana-tx-landing.yml +++ /dev/null @@ -1,216 +0,0 @@ -# OpenChainBench. Bench № 016 - -slug: solana-tx-landing -number: "016" -title: Solana transaction landing services market share -seo_title: "Solana tx landing services 2026: Jito, Helius Sender, Nozomi, bloXroute, 0slot, Astralane live market share" -seo_description: "Live observational benchmark of Solana transaction landing services. Market share by tx count, attribution via known on-chain tip wallets for Jito, Helius Sender, Nozomi, bloXroute, 0slot, NextBlock, Astralane, SolanaVibeStation. Zero on-chain footprint, methodology-public." -subtitle: Share of tipped Solana transactions attributed to each landing service, counted via known on-chain tip wallets. Observational, no transactions sent. -category: Trading -status: live -metric: Tx count (24h) -unit: count -higher_is_better: true - -disclaimer: | - Three caveats. (1) Market share, NOT landing rate, measuring success rates would require sending controlled tx, which costs SOL. (2) Jito is over-counted, other services (Helius Sender, Astralane, bloXroute) fan-out tips to Jito alongside their own wallet, inflating Jito's counter. (3) High share ≠ best, Nozomi has tiny share but charges 10x higher tips for guaranteed inclusion. Pick on latency / cost / reliability fit, not leaderboard position. - -seo_intro: | - This benchmark answers the question every Solana dev choosing a - transaction landing service asks. who actually carries the flow, - what does it cost, and is it growing or shrinking. Marketing - pages quote self-reported "99% landing rate" without methodology; - this page measures the reality on-chain by observing the known - tip wallets each service publishes in its docs. Every confirmed - Solana transaction that pays a tip to one of ~72 known landing- - service wallets is attributed to that service. Zero on-chain - footprint, we don't send tx ourselves, we watch the chain. - Coverage. 8 services with cleanly attributable tip wallets. - Jito Block Engine (the OG, 8 wallets), Helius Sender (10 wallets, - disjoint from Jito's pool), Nozomi by Temporal Labs (17 wallets - with `noz` vanity prefix), bloXroute Trader API (17 `bLx` wallets), - 0slot.trade (10 wallets), NextBlock (8 wallets), Astralane Iris, - SolanaVibeStation Lightspeed. Blind spots. Syncro Sender (per- - customer tip wallets, undisclosed), Slipstream (pure router whose - tx land via underlying senders' wallets and get attributed to - those), and any direct-RPC tx that doesn't pay a tip (a large - share of total Solana traffic, but not what this bench measures). - -abstract: | - We attribute Solana transactions to their landing service by - watching the on-chain tip wallets each service documents publicly. - A single WebSocket connection to mainnet-beta subscribes via - `logsSubscribe(mentions=[tip_wallet], commitment=confirmed)` for - each of ~72 tip wallets across 8 services. Every confirmed - notification increments a per-service counter; signatures are - deduplicated against a 50k-entry LRU so reconnect replays don't - double-count. Failed transactions (Err != null) are excluded - - they didn't actually land. The headline metric is total - landed-and-tipped tx attributed to each service in the last 24 - hours, surfaced as a leaderboard sorted by volume. This is the - observational counterpart to a controlled landing-rate benchmark. - it costs nothing to run (no tx sent, no SOL spent), provides - market-share signal that the controlled approach cannot (real - user behaviour, not synthetic probes), and stays neutral - (services can't fingerprint our probes, there are no probes). - Limitations. (a) Helius Sender fans some tx to Jito under the - hood; if Helius's own tip wallet is paid, attribution is clean, - but Helius's "dual-path" fallback to Jito-only payment would be - miscounted as Jito. (b) Services rotating tip wallets without - doc updates introduce silent under-counting until the new - addresses are added to the harness. The harness logs every - unattributable tip-pattern tx so the gap surfaces in operator - metrics. - -methodology: - - "Source: `wss://api.mainnet-beta.solana.com` public WebSocket, no API key. One `logsSubscribe({mentions:[wallet]}, {commitment:confirmed})` call per known tip wallet. Server-side filtered, bandwidth scales with matched traffic, not full firehose." - - "Attribution: 72 tip wallets across 8 services. Jito (8), Helius Sender (10), Nozomi (17), bloXroute (17), 0slot (10), NextBlock (8), Astralane (1), SolanaVibeStation (1). Every signature carries to exactly one service because the wallet sets are disjoint." - - "Dedup: 50,000-entry LRU keyed by signature. Reconnects can replay a few seconds of notifications, without dedup we'd double-count attribution. Memory cost ~4 MB." - - "Failed tx filter: notifications with `err != null` are dropped. The bench measures LANDED tx, not submission attempts. Failed-tx flow is its own measurement and lives outside this bench." - - "Reconnect: WebSocket disconnects (mainnet-beta is best-effort and kicks idle / overloaded clients) trigger exponential backoff 2s → 60s cap. Every (service, subscription) is re-established on reconnect; `solana_landing_reconnects_total` surfaces stability over the day." - - "Blind spots. Syncro Sender (P2P.org) uses per-customer tip wallets not publicly documented, invisible. Slipstream is a router that routes through underlying senders, so its tx are attributed to the underlying service (Jito/Nozomi/etc.). Direct-RPC tx that pay only a priority fee (no tip to a landing service) are not measured, they constitute the majority of Solana volume but they're not the subject of this bench." - - "Excluded by design: time-to-land, success rate per service, geographic latency. Those would require sending controlled tx (active probing) which costs SOL and risks services fingerprinting our probes. This bench is purely observational." - -findings: - - "{{best_name}} currently carries {{best_p50}} of attributed landed tx over the last 24 h, across {{count}} measured services. Jito's dominance is structural, most Solana wallets and trading bots default to the Jito Block Engine because it's the oldest, the cheapest at low tip amounts, and the only one offering atomic bundles." - - "{{name:helius-sender}} ships {{p50:helius-sender}}. Helius Sender's strength is the dual-path fan-out (Jito + SWQoS staked connections) which gives it second-place reach without owning the volume Jito does." - - "{{name:nozomi}} carries {{p50:nozomi}}. Nozomi has tiny share by volume but historically charges 10x higher tips per tx, used by serious traders who treat the higher tip as guaranteed-inclusion insurance rather than discretionary spend." - - "{{name:bloxroute}} sits at {{p50:bloxroute}}. bloXroute Trader API serves institutional flow with multi-path BDN propagation. Volume share is smaller than its revenue share because individual tips are higher." - - "{{worst_name}} trails at {{worst_p50}}. The long tail (NextBlock, Astralane, SolanaVibeStation, 0slot) collectively carries a few percent of attributed flow. These services compete on specific edges (regional co-location, validator stake size, tip floor) rather than universal coverage." - -faq: - - q: "What is a Solana landing service?" - a: "Solana has no traditional mempool. Transactions go directly to the current and next slot leader. During congestion, leaders drop transactions they can't process, they're simply lost, not queued. Landing services help your tx avoid being dropped via various tricks. direct connections to leaders (Nozomi, 0slot), stake-weighted QoS pools (Helius Sender, Triton Cascade), tip-based bundle auctions (Jito), multi-path propagation (bloXroute, Astralane). This bench measures which services actually carry the on-chain flow today." - - q: "Why observational? Why not measure landing rate directly?" - a: "Sending controlled tx through each service to measure landing rate costs real SOL (every tx = base fee + tip + compute units, all paid to validators, none recoverable). At a 30 s cadence across 5 services and 3 regions, that's ~$11,700/month. The observational approach costs $0 because we don't send anything, we watch the chain. Trade-off. observational tells you 'who is used' (market share, tip economics, growth); controlled tells you 'who lands best' (per-service success rate). Different questions, both useful. This is the observational answer." - - q: "How is each tx attributed to a service?" - a: "Each landing service publishes a list of known tip wallet addresses in its docs (Jito has 8, Nozomi has 17, etc.). When a user routes a tx through a service, the tx includes a transfer to that service's tip wallet. We watch all 72 tip wallets via `logsSubscribe(mentions=[wallet])` and attribute each notification to the owning service. Wallet sets are disjoint, no overlap between services, so attribution is unambiguous. The pattern is verified against real mainnet blocks: 95-97% of tip-paying tx attribute cleanly, zero multi-service overlaps observed." - - q: "Why does Jito have such dominant share?" - a: "Three reasons. (1) First-mover. Jito launched the tip+bundle pattern in 2022 and got every major wallet (Phantom, Backpack) + every major trading bot to integrate first. (2) Lowest tip floor, Jito accepts tips as low as 1,000 lamports versus 0.001 SOL minimums on most competitors. (3) Atomic bundles, only Jito offers the 5-tx atomic group that DEX aggregators (Jupiter, Raydium) use for sandwich-proof swaps. The result is structural: Jito carries the default flow even when competitors are technically faster on specific paths." - - q: "Why does Nozomi look small here?" - a: "Nozomi's share by tx count is small but its share by tip revenue is much larger. Nozomi targets serious traders who pay 0.001+ SOL per tx as guaranteed-inclusion insurance, while Jito averages ~0.0001 SOL per tx as casual usage. Blockworks Research's 'Solana Block Building Wars' (Feb 2026) documented Nozomi at ~$1M tips on 2M swaps vs Jito's $300k on 24M swaps, a 130x premium per swap. For 'who do serious traders use', track Nozomi. For 'who handles default flow', track Jito." - - q: "What about Syncro Sender and Slipstream?" - a: "Syncro Sender (P2P.org) uses per-customer tip wallets that aren't publicly documented, every customer gets a private address. We can't enumerate them, so Syncro flow is invisible to this bench. Estimated share: small (Syncro launched in 2025 and hasn't disclosed adoption metrics). Slipstream is a router that calls the underlying senders' SDKs, its tx land via Jito/Nozomi/0slot tip wallets and are attributed to those services. So Slipstream-originated flow shows up correctly counted, just under the underlying service's name. If you specifically want to know 'what % uses Slipstream as the router', this bench can't answer that, Slipstream itself doesn't add an identifying memo." - -source: https://github.com/OpenChainBench/OpenChainBench/tree/main/harnesses/solana-tx-landing - -prometheus: - window: 24h - -# Real metrics emitted by the solana-tx-landing harness: -# solana_landing_tx_total{service} counter (attributed landed tx) -# solana_landing_subscription_health{service} gauge (0|1) -# solana_landing_reconnects_total counter -# solana_landing_last_slot{service} gauge -# -# Each "provider" below is one landing service. The metric is -# observed throughput (tx/24h), so `higher_is_better: true` - -# the leader is the one carrying the most flow. p50/p90/p99 -# columns are filled with the 24h increase (single scalar) for -# consistency with the OCB renderer; they're not statistical -# distributions because each bench probe is a single counter. - -providers: - - slug: jito - name: Jito - tag: Block Engine, atomic bundles + tip auction, OG since 2022 - formula: "Total count over the last 24h of confirmed mainnet txs that paid a tip to one of Jito's 8 documented tip wallets, deduped via 50k-entry LRU." - queries: - p50: sum(increase(solana_landing_tx_total{service="jito"}[24h])) - p90: sum(increase(solana_landing_tx_total{service="jito"}[24h])) - p99: sum(increase(solana_landing_tx_total{service="jito"}[24h])) - mean: sum(increase(solana_landing_tx_total{service="jito"}[24h])) - success: avg_over_time(solana_landing_subscription_health{service="jito"}[24h]) - sample_size: sum(increase(solana_landing_tx_total{service="jito"}[24h])) - series: sum(rate(solana_landing_tx_total{service="jito"}[5m])) - - - slug: helius-sender - name: Helius Sender - tag: Dual-path fan-out (Jito + SWQoS staked connections), 7 regions - formula: "Total count over the last 24h of confirmed mainnet txs that paid a tip to one of Helius Sender's 10 documented tip wallets, deduped via 50k-entry LRU." - queries: - p50: sum(increase(solana_landing_tx_total{service="helius-sender"}[24h])) - p90: sum(increase(solana_landing_tx_total{service="helius-sender"}[24h])) - p99: sum(increase(solana_landing_tx_total{service="helius-sender"}[24h])) - mean: sum(increase(solana_landing_tx_total{service="helius-sender"}[24h])) - success: avg_over_time(solana_landing_subscription_health{service="helius-sender"}[24h]) - sample_size: sum(increase(solana_landing_tx_total{service="helius-sender"}[24h])) - series: sum(rate(solana_landing_tx_total{service="helius-sender"}[5m])) - - - slug: nozomi - name: Nozomi - tag: Temporal Labs, direct-to-leader, 9 co-located regions, premium tips - formula: "Total count over the last 24h of confirmed mainnet txs that paid a tip to one of Nozomi's 17 `noz`-prefixed tip wallets, deduped via 50k-entry LRU." - queries: - p50: sum(increase(solana_landing_tx_total{service="nozomi"}[24h])) - p90: sum(increase(solana_landing_tx_total{service="nozomi"}[24h])) - p99: sum(increase(solana_landing_tx_total{service="nozomi"}[24h])) - mean: sum(increase(solana_landing_tx_total{service="nozomi"}[24h])) - success: avg_over_time(solana_landing_subscription_health{service="nozomi"}[24h]) - sample_size: sum(increase(solana_landing_tx_total{service="nozomi"}[24h])) - series: sum(rate(solana_landing_tx_total{service="nozomi"}[5m])) - - - slug: bloxroute - name: bloXroute Trader API - tag: Multi-path BDN propagation, institutional flow - formula: "Total count over the last 24h of confirmed mainnet txs that paid a tip to one of bloXroute's 17 `bLx`-prefixed tip wallets, deduped via 50k-entry LRU." - queries: - p50: sum(increase(solana_landing_tx_total{service="bloxroute"}[24h])) - p90: sum(increase(solana_landing_tx_total{service="bloxroute"}[24h])) - p99: sum(increase(solana_landing_tx_total{service="bloxroute"}[24h])) - mean: sum(increase(solana_landing_tx_total{service="bloxroute"}[24h])) - success: avg_over_time(solana_landing_subscription_health{service="bloxroute"}[24h]) - sample_size: sum(increase(solana_landing_tx_total{service="bloxroute"}[24h])) - series: sum(rate(solana_landing_tx_total{service="bloxroute"}[5m])) - - - slug: 0slot - name: 0slot.trade - tag: Premium QUIC leader-direct sender - formula: "Total count over the last 24h of confirmed mainnet txs that paid a tip to one of 0slot.trade's 10 documented tip wallets, deduped via 50k-entry LRU." - queries: - p50: sum(increase(solana_landing_tx_total{service="0slot"}[24h])) - p90: sum(increase(solana_landing_tx_total{service="0slot"}[24h])) - p99: sum(increase(solana_landing_tx_total{service="0slot"}[24h])) - mean: sum(increase(solana_landing_tx_total{service="0slot"}[24h])) - success: avg_over_time(solana_landing_subscription_health{service="0slot"}[24h]) - sample_size: sum(increase(solana_landing_tx_total{service="0slot"}[24h])) - series: sum(rate(solana_landing_tx_total{service="0slot"}[5m])) - - - slug: nextblock - name: NextBlock - tag: SWQoS sender, TX Stream API - formula: "Total count over the last 24h of confirmed mainnet txs that paid a tip to one of NextBlock's 8 documented tip wallets, deduped via 50k-entry LRU." - queries: - p50: sum(increase(solana_landing_tx_total{service="nextblock"}[24h])) - p90: sum(increase(solana_landing_tx_total{service="nextblock"}[24h])) - p99: sum(increase(solana_landing_tx_total{service="nextblock"}[24h])) - mean: sum(increase(solana_landing_tx_total{service="nextblock"}[24h])) - success: avg_over_time(solana_landing_subscription_health{service="nextblock"}[24h]) - sample_size: sum(increase(solana_landing_tx_total{service="nextblock"}[24h])) - series: sum(rate(solana_landing_tx_total{service="nextblock"}[5m])) - - - slug: astralane - name: Astralane Iris - tag: Validator co-located, leader-schedule-aware routing - formula: "Total count over the last 24h of confirmed mainnet txs that paid a tip to Astralane Iris's documented tip wallet, deduped via 50k-entry LRU." - queries: - p50: sum(increase(solana_landing_tx_total{service="astralane"}[24h])) - p90: sum(increase(solana_landing_tx_total{service="astralane"}[24h])) - p99: sum(increase(solana_landing_tx_total{service="astralane"}[24h])) - mean: sum(increase(solana_landing_tx_total{service="astralane"}[24h])) - success: avg_over_time(solana_landing_subscription_health{service="astralane"}[24h]) - sample_size: sum(increase(solana_landing_tx_total{service="astralane"}[24h])) - series: sum(rate(solana_landing_tx_total{service="astralane"}[5m])) - - - slug: solanavibestation - name: SolanaVibeStation - tag: Lightspeed validator-pool tip-based sender - formula: "Total count over the last 24h of confirmed mainnet txs that paid a tip to SolanaVibeStation's documented Lightspeed tip wallet, deduped via 50k-entry LRU." - queries: - p50: sum(increase(solana_landing_tx_total{service="solanavibestation"}[24h])) - p90: sum(increase(solana_landing_tx_total{service="solanavibestation"}[24h])) - p99: sum(increase(solana_landing_tx_total{service="solanavibestation"}[24h])) - mean: sum(increase(solana_landing_tx_total{service="solanavibestation"}[24h])) - success: avg_over_time(solana_landing_subscription_health{service="solanavibestation"}[24h]) - sample_size: sum(increase(solana_landing_tx_total{service="solanavibestation"}[24h])) - series: sum(rate(solana_landing_tx_total{service="solanavibestation"}[5m])) diff --git a/benchmarks/stablecoin-peg-usdt-anchored.yml b/benchmarks/stablecoin-peg-usdt-anchored.yml index 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 deleted file mode 100644 index 930fcfd5..00000000 --- a/benchmarks/wallet-labels-coverage.yml +++ /dev/null @@ -1,217 +0,0 @@ -# OpenChainBench. Bench № 008 - -slug: wallet-labels-coverage -number: "008" -title: Best wallet labeling API provider -seo_title: "Best wallet labeling API in 2026: Blockscout, Helius, Mobula, Moralis, TonAPI live coverage audit" -seo_description: "Live wallet labeling API leaderboard. Share of well-known addresses (CEX hot wallets, DEX routers, Safe multisigs, OFAC SDN, public figures) each provider correctly identifies, audited every 30 minutes across 11 chains." -subtitle: Share of well-known addresses each provider correctly labels with an entity (CEX, DEX, multisig, sanctioned, etc.). -category: Aggregators -status: live -metric: Coverage rate -unit: pct -higher_is_better: true - -seo_intro: | - This benchmark answers the question that decides whether a wallet - labeling API is worth integrating. given a publicly-known address, - does the API actually identify it. Arkham and Nansen own the - consumer visualization layer; the builder side of the question lives - elsewhere, in the labeling APIs wallets, portfolio trackers and AML - flows hit under the hood. We push roughly 60 curated anchor - addresses (CEX hot wallets, DEX routers, Safe multisigs, OFAC SDN, - public figures) into a queue every 30 minutes and ask each provider - for an entity name. Hits count only when the response is a - meaningful name, generic fillers ("EOA", "Contract", "Wallet") are - excluded because they carry no entity signal. Coverage is reported - per chain so chain-specialists (Helius on Solana, TonAPI on TON, - StellarExpert on Stellar, XRPScan on XRP, WalletExplorer on Bitcoin) - are not unfairly penalised for chains they do not claim to cover. - Universal providers (Mobula, Moralis, Blockscout, OLI) are scored - on every chain they advertise. - -abstract: | - We benchmark how well each major wallet-labeling provider identifies a curated - sample of publicly-known addresses across 11 chains. The harness pushes ~60 - rotating anchor addresses (Etherscan public name tags, OFAC SDN list, Safe - multisigs, DEX routers, public figures) onto a queue and asks every supported - provider for an entity label. A provider scores a hit when it returns a - non-generic entity name; categorical fillers like "EOA" or "Contract" don't - count. - - Coverage is shown per chain. Some providers cover 90+ chains (Mobula), others - are chain-specialists (Helius for Solana, TonAPI for TON, StellarExpert for - Stellar, XRPScan for XRP, WalletExplorer for Bitcoin). Showing per-chain - rather than aggregating prevents specialists from being unfairly penalized - for chains they don't claim to cover. - -methodology: - - "Sample: ~60 curated anchor addresses (CEX hot wallets, DEX routers, Safe multisigs, OFAC SDN, public figures), shuffled and re-checked every 30 minutes. List rotated quarterly from public sources." - - "Query each provider's labels endpoint live: Mobula `/api/2/wallet/labels`, Helius `/v1/wallet/{addr}/identity`, Moralis `/api/v2.2/entities`, Blockscout `/api/v2/addresses/{addr}`, OLI Base EAS GraphQL, TonAPI `/v2/accounts/{addr}`, StellarExpert `/explorer/directory/{addr}`, XRPScan `/api/v1/account/{addr}`, WalletExplorer `/api/1/address-lookup`." - - "A provider scores 'hit' when it returns a non-generic entity name. Filler labels like 'EOA', 'Contract', 'Wallet', 'Multisig', 'Unknown' are explicitly excluded, they carry no entity signal." - - "Per-chain leaderboards. providers are filtered by their declared chain support, so Helius (Solana-only) doesn't get penalized on Ethereum and vice versa." - - "Failures (timeouts, 5xx, auth errors) are counted as 'no label' and surfaced separately as `wallet_labels_fetch_errors_total`." - - "Region: `eu-west` (single point)." - -findings: - - "{{best_name}} currently leads coverage at {{best_p50}} (24 h) on the active chain tab, across {{count}} measured providers. The number is the share of curated anchor addresses for which the provider returns a non-generic entity name, audited every 30 minutes against ~60 publicly-known wallets per chain." - - "{{name:mobula}} returns {{p50:mobula}}. Universal coverage providers score on every chain they advertise, so a single number summarizes how broad the underlying label graph actually is once chain-specialists are stripped out." - - "{{name:helius}} scores {{p50:helius}} on Solana. Chain-specialists tend to dominate their home chain because their label graph is curated against native protocols (Jito, Jupiter, Pump.fun, marketplace programs) rather than translated from an EVM-shaped schema." - - "{{name:moralis}} returns {{p50:moralis}}. EVM-centric providers usually trail on TON, Stellar, XRP and Bitcoin because their indexers and entity graphs were built for EVM patterns and ported chains afterwards." - - "{{name:blockscout}} sits at {{p50:blockscout}}. Explorer-derived labels rely on public name tags and chain-by-chain instances, so coverage tracks how active each chain's Blockscout deployment is rather than a single universal label graph." - - "{{worst_name}} trails at {{worst_p50}} on the active tab. The gap between leader and laggard is mostly which entities each provider's graph has been curated against: CEX hot wallets, OFAC SDN and Safe multisigs are easy hits; long-tail public figures and DEX routers separate the top tier." - -faq: - - q: "Which wallet labeling API has the best coverage right now?" - a: "{{best_name}} currently leads the active chain tab at {{best_p50}} (24 h) across {{count}} measured providers. The leaderboard re-sorts every 30 minutes against fresh Prometheus samples, so the answer reflects the last 24 hours of live audits against ~60 curated anchor addresses, not a marketing-page claim." - - 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." - - 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?" - a: "The harness maintains a curated list of ~60 publicly-known anchor addresses (CEX hot wallets, DEX routers, Safe multisigs, OFAC SDN, public figures) rotated quarterly from public sources (Etherscan public name tags, Treasury OFAC list, Safe registry). Every 30 minutes each address is pushed to every provider that declares support for its chain. A `hit` is recorded only when the response is a non-generic entity name. Coverage is `success_total / checks_total` over 24 hours, sliced per chain so chain-specialists are not penalised on chains they do not claim to cover." - - 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 - -prometheus: - window: 24h - expected_freshness_seconds: 5400 - -# Chain selector. tabs at the top of the page. Server injects -# `chain="X"` into every PromQL query for the active tab. The special -# 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 } - - { value: base, label: Base } - - { value: arbitrum, label: Arbitrum } - - { value: polygon, label: Polygon } - - { value: optimism, label: Optimism } - - { value: ton, label: TON } - - { value: stellar, label: Stellar } - - { value: xrp, label: XRP } - - { value: bitcoin, label: Bitcoin } - -# Real metrics emitted by the wallet-labels harness: -# wallet_labels_checks_total{provider, chain} counter -# wallet_labels_success_total{provider, chain} counter -# wallet_labels_api_latency_milliseconds{provider} histogram -# wallet_labels_fetch_errors_total{provider, error_type} counter -# wallet_labels_health{provider} gauge (0|1) -# wallet_labels_queue_depth gauge - -providers: - - slug: mobula - name: Mobula - formula: "Share of ~60 anchor addresses for which Mobula's /api/2/wallet/labels returns a non-generic entity name, computed as success_total ÷ checks_total over 24h." - queries: - p50: 100 * sum(increase(wallet_labels_success_total{provider="mobula"}[24h])) / sum(increase(wallet_labels_checks_total{provider="mobula"}[24h])) - p90: 100 * sum(increase(wallet_labels_success_total{provider="mobula"}[24h])) / sum(increase(wallet_labels_checks_total{provider="mobula"}[24h])) - p99: 100 * sum(increase(wallet_labels_success_total{provider="mobula"}[24h])) / sum(increase(wallet_labels_checks_total{provider="mobula"}[24h])) - mean: 100 * sum(increase(wallet_labels_success_total{provider="mobula"}[24h])) / sum(increase(wallet_labels_checks_total{provider="mobula"}[24h])) - success: clamp_max(sum(rate(wallet_labels_checks_total{provider="mobula"}[24h])) / scalar(sum(rate(wallet_labels_checks_total{provider="mobula"}[24h]))), 1) - sample_size: sum(increase(wallet_labels_checks_total{provider="mobula"}[24h])) - series: 100 * sum(rate(wallet_labels_success_total{provider="mobula"}[1h])) / sum(rate(wallet_labels_checks_total{provider="mobula"}[1h])) - - - slug: helius - name: Helius - formula: "Share of Solana anchor addresses for which Helius /v1/wallet/{addr}/identity returns a non-generic entity name, success_total ÷ checks_total over 24h." - queries: - p50: 100 * sum(increase(wallet_labels_success_total{provider="helius"}[24h])) / sum(increase(wallet_labels_checks_total{provider="helius"}[24h])) - p90: 100 * sum(increase(wallet_labels_success_total{provider="helius"}[24h])) / sum(increase(wallet_labels_checks_total{provider="helius"}[24h])) - p99: 100 * sum(increase(wallet_labels_success_total{provider="helius"}[24h])) / sum(increase(wallet_labels_checks_total{provider="helius"}[24h])) - mean: 100 * sum(increase(wallet_labels_success_total{provider="helius"}[24h])) / sum(increase(wallet_labels_checks_total{provider="helius"}[24h])) - success: clamp_max(sum(rate(wallet_labels_checks_total{provider="helius"}[24h])) / scalar(sum(rate(wallet_labels_checks_total{provider="helius"}[24h]))), 1) - sample_size: sum(increase(wallet_labels_checks_total{provider="helius"}[24h])) - series: 100 * sum(rate(wallet_labels_success_total{provider="helius"}[1h])) / sum(rate(wallet_labels_checks_total{provider="helius"}[1h])) - - - slug: moralis - name: Moralis - formula: "Share of anchor addresses for which Moralis /api/v2.2/entities returns a non-generic entity name across declared EVM chains, success_total ÷ checks_total over 24h." - queries: - p50: 100 * sum(increase(wallet_labels_success_total{provider="moralis"}[24h])) / sum(increase(wallet_labels_checks_total{provider="moralis"}[24h])) - p90: 100 * sum(increase(wallet_labels_success_total{provider="moralis"}[24h])) / sum(increase(wallet_labels_checks_total{provider="moralis"}[24h])) - p99: 100 * sum(increase(wallet_labels_success_total{provider="moralis"}[24h])) / sum(increase(wallet_labels_checks_total{provider="moralis"}[24h])) - mean: 100 * sum(increase(wallet_labels_success_total{provider="moralis"}[24h])) / sum(increase(wallet_labels_checks_total{provider="moralis"}[24h])) - success: clamp_max(sum(rate(wallet_labels_checks_total{provider="moralis"}[24h])) / scalar(sum(rate(wallet_labels_checks_total{provider="moralis"}[24h]))), 1) - sample_size: sum(increase(wallet_labels_checks_total{provider="moralis"}[24h])) - series: 100 * sum(rate(wallet_labels_success_total{provider="moralis"}[1h])) / sum(rate(wallet_labels_checks_total{provider="moralis"}[1h])) - - - slug: blockscout - name: Blockscout - formula: "Share of anchor addresses for which Blockscout /api/v2/addresses/{addr} returns a non-generic public name tag, success_total ÷ checks_total over 24h." - queries: - p50: 100 * sum(increase(wallet_labels_success_total{provider="blockscout"}[24h])) / sum(increase(wallet_labels_checks_total{provider="blockscout"}[24h])) - p90: 100 * sum(increase(wallet_labels_success_total{provider="blockscout"}[24h])) / sum(increase(wallet_labels_checks_total{provider="blockscout"}[24h])) - p99: 100 * sum(increase(wallet_labels_success_total{provider="blockscout"}[24h])) / sum(increase(wallet_labels_checks_total{provider="blockscout"}[24h])) - mean: 100 * sum(increase(wallet_labels_success_total{provider="blockscout"}[24h])) / sum(increase(wallet_labels_checks_total{provider="blockscout"}[24h])) - success: clamp_max(sum(rate(wallet_labels_checks_total{provider="blockscout"}[24h])) / scalar(sum(rate(wallet_labels_checks_total{provider="blockscout"}[24h]))), 1) - sample_size: sum(increase(wallet_labels_checks_total{provider="blockscout"}[24h])) - series: 100 * sum(rate(wallet_labels_success_total{provider="blockscout"}[1h])) / sum(rate(wallet_labels_checks_total{provider="blockscout"}[1h])) - - - slug: oli - name: OLI - formula: "Share of anchor addresses for which the OLI Base EAS GraphQL endpoint returns a non-generic entity attestation, success_total ÷ checks_total over 24h." - queries: - p50: 100 * sum(increase(wallet_labels_success_total{provider="oli"}[24h])) / sum(increase(wallet_labels_checks_total{provider="oli"}[24h])) - p90: 100 * sum(increase(wallet_labels_success_total{provider="oli"}[24h])) / sum(increase(wallet_labels_checks_total{provider="oli"}[24h])) - p99: 100 * sum(increase(wallet_labels_success_total{provider="oli"}[24h])) / sum(increase(wallet_labels_checks_total{provider="oli"}[24h])) - mean: 100 * sum(increase(wallet_labels_success_total{provider="oli"}[24h])) / sum(increase(wallet_labels_checks_total{provider="oli"}[24h])) - success: clamp_max(sum(rate(wallet_labels_checks_total{provider="oli"}[24h])) / scalar(sum(rate(wallet_labels_checks_total{provider="oli"}[24h]))), 1) - sample_size: sum(increase(wallet_labels_checks_total{provider="oli"}[24h])) - series: 100 * sum(rate(wallet_labels_success_total{provider="oli"}[1h])) / sum(rate(wallet_labels_checks_total{provider="oli"}[1h])) - - - slug: tonapi - name: TonAPI - formula: "Share of TON anchor addresses for which TonAPI /v2/accounts/{addr} returns a non-generic entity name, success_total ÷ checks_total over 24h." - queries: - p50: 100 * sum(increase(wallet_labels_success_total{provider="tonapi"}[24h])) / sum(increase(wallet_labels_checks_total{provider="tonapi"}[24h])) - p90: 100 * sum(increase(wallet_labels_success_total{provider="tonapi"}[24h])) / sum(increase(wallet_labels_checks_total{provider="tonapi"}[24h])) - p99: 100 * sum(increase(wallet_labels_success_total{provider="tonapi"}[24h])) / sum(increase(wallet_labels_checks_total{provider="tonapi"}[24h])) - mean: 100 * sum(increase(wallet_labels_success_total{provider="tonapi"}[24h])) / sum(increase(wallet_labels_checks_total{provider="tonapi"}[24h])) - success: clamp_max(sum(rate(wallet_labels_checks_total{provider="tonapi"}[24h])) / scalar(sum(rate(wallet_labels_checks_total{provider="tonapi"}[24h]))), 1) - sample_size: sum(increase(wallet_labels_checks_total{provider="tonapi"}[24h])) - series: 100 * sum(rate(wallet_labels_success_total{provider="tonapi"}[1h])) / sum(rate(wallet_labels_checks_total{provider="tonapi"}[1h])) - - - slug: stellarexpert - name: StellarExpert - formula: "Share of Stellar anchor addresses for which StellarExpert /explorer/directory/{addr} returns a non-generic entity name, success_total ÷ checks_total over 24h." - queries: - p50: 100 * sum(increase(wallet_labels_success_total{provider="stellarexpert"}[24h])) / sum(increase(wallet_labels_checks_total{provider="stellarexpert"}[24h])) - p90: 100 * sum(increase(wallet_labels_success_total{provider="stellarexpert"}[24h])) / sum(increase(wallet_labels_checks_total{provider="stellarexpert"}[24h])) - p99: 100 * sum(increase(wallet_labels_success_total{provider="stellarexpert"}[24h])) / sum(increase(wallet_labels_checks_total{provider="stellarexpert"}[24h])) - mean: 100 * sum(increase(wallet_labels_success_total{provider="stellarexpert"}[24h])) / sum(increase(wallet_labels_checks_total{provider="stellarexpert"}[24h])) - success: clamp_max(sum(rate(wallet_labels_checks_total{provider="stellarexpert"}[24h])) / scalar(sum(rate(wallet_labels_checks_total{provider="stellarexpert"}[24h]))), 1) - sample_size: sum(increase(wallet_labels_checks_total{provider="stellarexpert"}[24h])) - series: 100 * sum(rate(wallet_labels_success_total{provider="stellarexpert"}[1h])) / sum(rate(wallet_labels_checks_total{provider="stellarexpert"}[1h])) - - - slug: xrpscan - name: XRPScan - formula: "Share of XRP anchor addresses for which XRPScan /api/v1/account/{addr} returns a non-generic entity name, success_total ÷ checks_total over 24h." - queries: - p50: 100 * sum(increase(wallet_labels_success_total{provider="xrpscan"}[24h])) / sum(increase(wallet_labels_checks_total{provider="xrpscan"}[24h])) - p90: 100 * sum(increase(wallet_labels_success_total{provider="xrpscan"}[24h])) / sum(increase(wallet_labels_checks_total{provider="xrpscan"}[24h])) - p99: 100 * sum(increase(wallet_labels_success_total{provider="xrpscan"}[24h])) / sum(increase(wallet_labels_checks_total{provider="xrpscan"}[24h])) - mean: 100 * sum(increase(wallet_labels_success_total{provider="xrpscan"}[24h])) / sum(increase(wallet_labels_checks_total{provider="xrpscan"}[24h])) - success: clamp_max(sum(rate(wallet_labels_checks_total{provider="xrpscan"}[24h])) / scalar(sum(rate(wallet_labels_checks_total{provider="xrpscan"}[24h]))), 1) - sample_size: sum(increase(wallet_labels_checks_total{provider="xrpscan"}[24h])) - series: 100 * sum(rate(wallet_labels_success_total{provider="xrpscan"}[1h])) / sum(rate(wallet_labels_checks_total{provider="xrpscan"}[1h])) - - - slug: walletexplorer - name: WalletExplorer - formula: "Share of Bitcoin anchor addresses for which WalletExplorer /api/1/address-lookup returns a non-generic entity name, success_total ÷ checks_total over 24h." - queries: - p50: 100 * sum(increase(wallet_labels_success_total{provider="walletexplorer"}[24h])) / sum(increase(wallet_labels_checks_total{provider="walletexplorer"}[24h])) - p90: 100 * sum(increase(wallet_labels_success_total{provider="walletexplorer"}[24h])) / sum(increase(wallet_labels_checks_total{provider="walletexplorer"}[24h])) - p99: 100 * sum(increase(wallet_labels_success_total{provider="walletexplorer"}[24h])) / sum(increase(wallet_labels_checks_total{provider="walletexplorer"}[24h])) - mean: 100 * sum(increase(wallet_labels_success_total{provider="walletexplorer"}[24h])) / sum(increase(wallet_labels_checks_total{provider="walletexplorer"}[24h])) - success: clamp_max(sum(rate(wallet_labels_checks_total{provider="walletexplorer"}[24h])) / scalar(sum(rate(wallet_labels_checks_total{provider="walletexplorer"}[24h]))), 1) - sample_size: sum(increase(wallet_labels_checks_total{provider="walletexplorer"}[24h])) - series: 100 * sum(rate(wallet_labels_success_total{provider="walletexplorer"}[1h])) / sum(rate(wallet_labels_checks_total{provider="walletexplorer"}[1h])) 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/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/infrastructure/prometheus/prometheus.yml b/infrastructure/prometheus/prometheus.yml index ffc66b96..1850ca87 100644 --- a/infrastructure/prometheus/prometheus.yml +++ b/infrastructure/prometheus/prometheus.yml @@ -105,6 +105,27 @@ scrape_configs: benchmark: perp-fees host: mobula + # ─ PM data freshness ───────────────────────────────────────────────── + # Cross-correlates Polymarket CLOB WebSocket (T0) vs Mobula PM WS vs + # Codex (defined.fi) GraphQL WS for the same trade. Reports per-provider + # millisecond lag. + # + # Scraped over Railway internal DNS because the harness lives in the + # same `Dashboard OpenChainBench` Railway project as this Prometheus. + # Public *.up.railway.app routes go through the edge and Railway does + # not always allow same-project traffic to loop back via the public + # gateway. Internal DNS is HTTP on port 2112 (the harness's exposed + # /metrics port) — never goes through Cloudflare / edge. + - job_name: pm-freshness + metrics_path: /metrics + scheme: http + static_configs: + - targets: + - pm-freshness-bench-production.railway.internal:2112 + labels: + benchmark: pm-freshness + host: mobula + # ─ Self-scrape ──────────────────────────────────────────────────────── # Surfaces Prometheus's own health (scrape error rates, target up/down) # in the same UI used to debug harnesses. 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/polymarket.png b/public/logos/polymarket.png new file mode 100644 index 00000000..0b69a6a6 Binary files /dev/null and b/public/logos/polymarket.png differ 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..e95babd9 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, @@ -232,7 +250,7 @@ export default async function AlternativePage({

/ + * 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..28ca1d93 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,42 @@ 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_", + // PM freshness bench + "pm_", + // 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 +322,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 +366,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 +105,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 }, }; } @@ -96,33 +154,63 @@ export default async function BenchmarkPage({ if (!aggregate) notFound(); const chainOptions = aggregate.dimensions?.chain ?? []; const regionOptions = aggregate.dimensions?.region ?? []; + const kindOptions = aggregate.dimensions?.kind ?? []; const chain = chainOptions[0]?.value ?? null; const region = regionOptions[0]?.value ?? null; + const kind = kindOptions[0]?.value ?? null; - // Pre-fetch every (chain × region) variant in parallel so client flips + // Pre-fetch every (chain × region × kind) variant in parallel so client flips // are zero round-trip. unstable_cache dedupes each (slug, filters) combo // across users - first miss warms it, every later viewer gets it instant. // `all` is the "no filter" sentinel - same as the unscoped fetch. const chainsForFetch = chainOptions.length > 0 ? chainOptions.map((c) => c.value) : [null]; const regionsForFetch = regionOptions.length > 0 ? regionOptions.map((r) => r.value) : [null]; + const kindsForFetch = kindOptions.length > 0 ? kindOptions.map((k) => k.value) : [null]; const variantPairs = chainsForFetch.flatMap((c) => - regionsForFetch.map((r) => [c, r] as const) + regionsForFetch.flatMap((r) => + kindsForFetch.map((k) => [c, r, k] as const) + ) ); const [variantList, all] = await Promise.all([ Promise.all( - variantPairs.map(async ([c, r]) => { - const filters: { chain?: string; region?: string } = {}; + variantPairs.map(async ([c, r, k]) => { + const filters: { chain?: string; region?: string; kind?: string } = {}; if (c && c !== "all") filters.chain = c; if (r && r !== "all") filters.region = r; + if (k && k !== "all") filters.kind = k; const b = await getBenchmark(slug, filters); - return [variantKey(c, r), b ?? aggregate] as const; + return [variantKey(c, r, k), b ?? aggregate] as const; }) ), getBenchmarks(), ]); - const variants: Record = Object.fromEntries(variantList); - const benchmark = variants[variantKey(chain, region)] ?? aggregate; + // 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, kind)] ?? aggregate; const isDraft = benchmark.status === "draft"; const isAwaiting = isDraft && benchmark.editorialStatus === "live"; @@ -224,7 +312,7 @@ export default async function BenchmarkPage({ const faqJsonLd = buildFaqPageJsonLd(benchmark.faq, benchmarkUrl); return ( -
+