diff --git a/.claude/skills/contribute-benchmark/SKILL.md b/.claude/skills/contribute-benchmark/SKILL.md index b0d732e9..65e094c1 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/ChainBench/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/OpenChainBench/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/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. +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. ### 2. Write the spec diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md index cacf0937..e46d62f9 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/ChainBench/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/OpenChainBench/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/ChainBench/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/OpenChainBench/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 7d31e822..870b4856 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**: `ChainBench/OpenChainBench` +- **Linked repository**: `OpenChainBench/OpenChainBench` ### Columns @@ -41,7 +41,7 @@ Requested → Approved → In progress → Live → Archived In Project Settings → Workflows, enable: -- **Auto-add to project**. for issues in `ChainBench/OpenChainBench` matching the filter `is:issue label:bench-request`. They land in `Requested` automatically. +- **Auto-add to project**. for issues in `OpenChainBench/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 483e473b..00392232 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/ChainBench/OpenChainBench/discussions/new?category=ideas + url: https://github.com/OpenChainBench/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/ChainBench/OpenChainBench/discussions/new?category=q-a + url: https://github.com/OpenChainBench/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/ChainBench/OpenChainBench/discussions/new?category=show-and-tell + url: https://github.com/OpenChainBench/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/ChainBench/OpenChainBench/security/advisories/new + url: https://github.com/OpenChainBench/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 e8ca74f1..3968a601 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/ChainBench/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/OpenChainBench/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/ChainBench/OpenChainBench/tree/main/benchmarks) and [open issues](https://github.com/ChainBench/OpenChainBench/issues?q=is%3Aissue+label%3Abench-request) for duplicates. + - 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. required: true diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 1e7bf15e..150f0331 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -2,7 +2,7 @@ ## Reporting a vulnerability -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. +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. If GitHub is not an option, email **security@openchainbench.com** with: diff --git a/.github/SUPPORT.md b/.github/SUPPORT.md index 42f72408..4cf1799d 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/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) | +| 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) | | 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 528eec40..65233157 100644 --- a/.github/workflows/staging-deploy.yml +++ b/.github/workflows/staging-deploy.yml @@ -1,5 +1,15 @@ 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: @@ -51,7 +61,7 @@ jobs: vercel alias set "${{ steps.deploy.outputs.url }}" \ staging-openchainbench.vercel.app \ --token=${{ secrets.VERCEL_TOKEN }} \ - --scope=${{ secrets.VERCEL_ORG_ID }} + --scope=mobula-labs - name: Comment summary run: | diff --git a/AGENTS.md b/AGENTS.md index 7502e8c0..d9a6850a 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/staging-deploy.yml` which calls Vercel CLI with a token and emits a Preview URL. +- `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. **Standard flow:** diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4b72e48b..b089f6cb 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/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) | +| 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) | | 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/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. +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. 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/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. +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. ## Code of conduct & security diff --git a/README.md b/README.md index bbefe614..104ede6e 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/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. +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. 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/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 +- 💡 [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 - 🗺️ [Roadmap](https://github.com/orgs/OpenChainBench/projects). what's planned and what's live -- 🐞 [New issue](https://github.com/ChainBench/OpenChainBench/issues/new/choose). formal benchmark proposal, data-quality flag, or provider correction +- 🐞 [New issue](https://github.com/OpenChainBench/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. [ChainBench/OpenChainBench](https://github.com/ChainBench/OpenChainBench) +- GitHub. [OpenChainBench/OpenChainBench](https://github.com/OpenChainBench/OpenChainBench) ## License diff --git a/benchmarks/aggregator-head-lag.yml b/benchmarks/aggregator-head-lag.yml index 7be2f7d4..9187b13e 100644 --- a/benchmarks/aggregator-head-lag.yml +++ b/benchmarks/aggregator-head-lag.yml @@ -19,13 +19,7 @@ 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. 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}}. + against canonical-tip archive nodes on Base, BNB Chain and Solana. 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 @@ -51,23 +45,21 @@ methodology: - "Cardinality: 3 aggregators × 3 chains × 3 regions = 27 active series." findings: - - "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." + - "{{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." - "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/ChainBench/OpenChainBench/tree/main/harnesses/aggregator-head-lag +source: https://github.com/OpenChainBench/OpenChainBench/tree/main/harnesses/aggregator-head-lag prometheus: window: 24h faq: - q: "Which crypto data API has the lowest latency right now?" - 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." + 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." - 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?" @@ -170,4 +162,3 @@ 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 458204fc..890a266b 100644 --- a/benchmarks/bridge-fee.yml +++ b/benchmarks/bridge-fee.yml @@ -46,26 +46,16 @@ 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 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." + - "{{best_name}} currently leads at {{best_p50}} (p50, 24 h) on the $300 USDC corridor, across {{count}} measured bridges." - "{{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 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." + - "{{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." faq: - q: "What is the cheapest cross-chain bridge right now?" - 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." + 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." - 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?" @@ -77,7 +67,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/ChainBench/OpenChainBench/tree/main/harnesses/bridge-monitor +source: https://github.com/OpenChainBench/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 b3cd1400..4a637a53 100644 --- a/benchmarks/bridge-quote-latency.yml +++ b/benchmarks/bridge-quote-latency.yml @@ -43,23 +43,15 @@ 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 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." + - "{{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." - "{{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 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." + - "{{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." -source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/bridge-monitor +source: https://github.com/OpenChainBench/OpenChainBench/tree/main/harnesses/bridge-monitor prometheus: url: https://prometheus-production-9ffe.up.railway.app @@ -68,7 +60,7 @@ prometheus: faq: - q: "Which cross-chain bridge has the lowest API latency?" - 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." + 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." - 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/bridge-revenue.yml b/benchmarks/bridge-revenue.yml deleted file mode 100644 index a9dd77d6..00000000 --- a/benchmarks/bridge-revenue.yml +++ /dev/null @@ -1,169 +0,0 @@ -# OpenChainBench. Bench № 028 - -slug: bridge-revenue -number: "028" -title: Cross-chain bridge implied protocol revenue -seo_title: "Bridge revenue tracker 2026: cross-chain protocol implied margin USD per day, take rate bps, swap volume (Relay + comparable bridges)" -seo_description: "Live cross-chain bridge implied protocol revenue. (USD in − USD out − gas − app fees) summed over swaps in 24h / 7d / 30d. Upper bound on what each bridge + solvers keep. Relay tracked today; Across / Socket / deBridge to follow. Methodology pre-registered, harness OSS." -subtitle: Implied margin in USD that each cross-chain bridge plus its solver network collectively retain per swap, summed over rolling 24h / 7d / 30d windows. Upper bound, not exact. Read methodology before citing. -category: Bridges -status: live -metric: Implied revenue USD (24h) -unit: usd -higher_is_better: true - -disclaimer: | - This is an UPPER BOUND, not an exact protocol-revenue number. The margin = USD-in minus USD-out minus gas minus disclosed app fees. Some of that margin Relay shares with solvers in ways we cannot distinguish from the public swap data, so the bench attributes the full amount to "Relay plus solvers collectively". Treat the headline as a ceiling on Relay's own take, not a P&L line. Unpriced swaps (token without a USD reference at swap time) are excluded from the sum and counted separately. - -seo_intro: | - This benchmark answers a question that has no honest public - answer today. how much money does Relay.link, the leading - cross-chain swap routing protocol, actually retain from the - flow it processes. The bench computes implied margin per swap - as (USD value the user paid) minus (USD value the user - received) minus (gas total in USD) minus (any external app - fees the API discloses), then sums that margin over rolling - 24-hour, 7-day and 30-day windows. The result is the smallest - possible number consistent with the on-chain swap data, but - not necessarily the smallest possible number consistent with - Relay's books. Relay shares revenue with the solver network - in ways the public API does not break out, so the bench - attributes the full margin to "Relay plus solvers collectively". - Treat the headline as a ceiling on what Relay alone keeps. - Why publish an upper bound. Because no lower bound exists in - public data. Relay does not publish a fee schedule mapping - volume to revenue. Solver-rebate arrangements are private. - Community-tracked volume ($80M-$150M per day per public - disclosures) times a market-implied 1-5 bps blended take - rate yields an expected band of $20k-$100k per day, or - $0.75M-$2.5M per month. This bench tightens that band by - measuring directly from the swap stream and discloses every - methodology choice so readers can reproduce or argue with - the number. - How it works. A Go program polls the Relay.link public swap - API every 60 s, joins each completed swap against a USD - reference at the swap timestamp, subtracts gas and disclosed - external app fees, and emits the resulting margin as a - Prometheus gauge labeled by window. Unpriced swaps are - excluded from the sum and counted separately. Headline - numbers render as p50 / p90 / p99 on the bench page; those - slots are repurposed for 24h / 7d / 30d windows of the same - margin metric. Take rate in bps is published alongside. - -abstract: | - Relay.link is a cross-chain swap routing protocol that - publishes per-swap detail through a public API. This - benchmark consumes that API on a fixed cadence, computes - per-swap implied margin in USD as (input USD value) minus - (output USD value) minus (gas USD) minus (disclosed external - app fees), and sums the margin over rolling 24-hour, 7-day - and 30-day windows. The result is published as the protocol- - revenue upper bound. Volume USD, swap count and the implied - take rate in basis points are surfaced alongside as context. - The bench does not have access to Relay's internal solver- - rebate accounting, so the number is interpreted as the - margin retained collectively by Relay plus its solver - network, not Relay's own bottom-line revenue. Cross- - validation. Public disclosures put Relay volume at $80M to - $150M per day. Bridge / aggregator take rates cluster at 1 - to 5 basis points blended. Multiplying yields an expected - protocol-revenue band of $20k to $100k per day, or $0.75M - to $2.5M per month, which the live bench number should fall - inside. Significant deviation is either a data-quality issue - the methodology section flags (price-feed gap, unpriced - swap surge, API change) or genuine signal that solver share - has shifted. Limitations. The margin definition is an upper - bound. Solver rebates would lower it; data-quality gaps - (missing USD reference at swap time, gas estimation drift, - undisclosed app fees) would lower the published headline - relative to true protocol revenue. The harness publishes - unpriced-swap count and last-poll timestamp as freshness - signals so readers can spot data-quality regressions. - -methodology: - - "Data source. The Relay.link public swap API is polled on a fixed cadence (default 60 s). The harness consumes the swap stream incrementally, deduping by swap ID, and emits each completed swap as a single contribution to the running window sums. Only swaps with status `success` are counted; pending or failed swaps are tracked separately and excluded from the revenue sum." - - "Margin definition. per_swap_margin_usd = input_usd - output_usd - gas_usd - external_app_fees_usd. input_usd and output_usd are computed by joining the on-chain token amounts against a USD reference at the swap timestamp from CoinGecko or equivalent. gas_usd is the chain-native gas cost (gas_used * gas_price) converted to USD at the same timestamp. external_app_fees_usd is the sum of any frontend or referrer fees the swap payload discloses." - - "Window sums. relay_revenue_usd{window=\"24h\"} is the rolling 24-hour sum of per-swap margin. relay_revenue_usd{window=\"7d\"} is the rolling 7-day sum. relay_revenue_usd{window=\"30d\"} is the rolling 30-day sum. relay_volume_usd and relay_swap_count are computed the same way over the same windows. The bench page binds the three timescales to the p50 / p90 / p99 slots respectively, so the leaderboard surfaces all three at a glance." - - "Take rate. relay_take_rate_bps = (relay_revenue_usd{window=\"24h\"} / relay_volume_usd{window=\"24h\"}) * 10000. Reported on the 24-hour window as the most recent operational signal. Expected band per cross-validation is 1-5 bps blended; a take-rate reading outside that band is either a data-quality issue or a regime change worth investigating." - - "Pricing coverage. relay_swaps_priced_total counts swaps where every leg had a USD reference at swap time. relay_swaps_unpriced_total counts swaps where at least one leg did not (long-tail token, missing reference, price-feed gap). The success column on the bench page is priced / (priced + unpriced), a data-quality ratio rather than a service-uptime ratio. A low ratio means the published headline understates true volume and margin." - - "Why margin is an upper bound on protocol revenue. Relay shares revenue with its solver network through arrangements the public API does not break out. Per-swap margin therefore captures what Relay AND solvers collectively retain, not Relay alone. Without access to internal rebate accounting, the bench cannot decompose the split. Headline is published as 'Relay implied protocol revenue (upper bound)'. Readers building a P&L model should apply their own solver-share assumption to the headline." - - "Freshness. relay_last_poll_timestamp_seconds is a Unix-timestamp gauge of the most recent successful API poll. The bench page's success indicator and any cron alerter use this to detect API outages or harness stalls. expected_freshness_seconds is set to 300 (5 min) which is 5x the 60 s poll cadence, so one missed cycle is tolerated and two missed cycles fire." - - "Cross-validation expected band. Public disclosures put Relay daily volume at $80M to $150M per day. Industry-standard cross-chain take rates cluster at 1 to 5 basis points blended. Multiplying the two ranges yields an expected revenue band of $20k to $100k per day, or $0.75M to $2.5M per month. The live number should fall inside this band. Significant deviation is either a data-quality issue or genuine signal that solver share has shifted." - - "Reproducibility. The full harness source is published in the OpenChainBench harnesses directory (Go). Anyone can clone, set the Relay.link API endpoint env var, run the binary against a Prometheus scraper, and reproduce these metrics. The bench does not rely on any private Mobula service for measurement, the only external dependency is the Relay.link public API and a public USD price reference (CoinGecko or equivalent)." - - "Methodology versioning. Any change to the margin definition, window sizes, pricing source, or excluded-swap rules ships as a public PR with a 14-day comment window. Major changes (e.g. switching from upper-bound margin to a solver-net-decomposed model once private data becomes available) run a 30-day shadow period publishing old and new metric series in parallel." - -findings: - - "Relay implied protocol revenue (24h, upper bound) is currently {{p50:relay}}. Sum of (USD paid - USD received - gas - disclosed app fees) across every successful swap in the last 24 hours. This is the smallest number consistent with the public on-chain swap data and an upper bound on what Relay alone retains after solver-share rebates." - - "The 7-day rolling sum sits at {{p90:relay}}. Smoothing across a week removes day-of-week volume effects and the noise from a single large arbitrage swap that can dominate a 24-hour window. Read this number as the steady-state protocol-revenue ceiling." - - "The 30-day rolling sum is {{p99:relay}}. Monthly recurring revenue ceiling, useful for comparing against public revenue disclosures from comparable cross-chain protocols and against the $0.75M-$2.5M per month expected band derived from volume times take-rate cross-validation." - - "Implied take rate over the last 24 hours is approximately (relay_revenue_usd / relay_volume_usd) * 10000 basis points. Expected blended band per industry cross-validation is 1 to 5 bps. A reading outside this band is either a data-quality issue (priced-swap ratio dropping, gas-estimation drift) or genuine signal that solver share has shifted." - - "Pricing coverage (priced swaps / total swaps over 24h) controls how much of true volume the headline captures. A 100 % ratio means every swap was joined against a USD reference and the headline reflects the full flow. A lower ratio means long-tail tokens are dropping out of the margin sum and the published number understates true protocol revenue proportionally." - -faq: - - q: "Is this Relay.link's actual revenue?" - a: "No. It is an upper bound. Implied margin equals USD paid minus USD received minus gas minus disclosed app fees, summed over completed swaps. Relay shares an unknown fraction of that margin with its solver network through arrangements the public API does not break out. The headline therefore captures what Relay AND its solver network collectively retain, not Relay's own bottom-line revenue. Without access to internal rebate accounting, the bench cannot decompose the split. Treat the headline as a ceiling and apply your own solver-share assumption if you need a Relay-only number." - - q: "Why publish an upper bound instead of an exact number?" - a: "Because no exact number exists in public data and an upper bound is the most rigorous estimate the public swap stream supports. Relay does not publish a fee schedule mapping volume to protocol revenue, and solver-rebate arrangements are private. The alternatives are either to publish nothing, or to publish a guess. An upper bound computed directly from the swap data is more useful than a guess, and is honest about the residual uncertainty. The methodology section spells out exactly what the upper-bound assumption is so readers can apply their own adjustments." - - q: "What does the expected band of $20k-$100k per day come from?" - a: "Cross-validation. Public disclosures put Relay daily volume at $80M to $150M per day. Industry-standard cross-chain take rates cluster at 1 to 5 basis points blended (bridge aggregators and routing protocols typically sit in this range). Multiplying the two ranges yields an expected revenue band of $20k to $100k per day, or $0.75M to $2.5M per month. The live bench number should fall inside this band. A reading meaningfully outside it is either a data-quality issue the methodology flags, or a signal that the cross-validation assumptions need to be revisited." - - q: "Why are 24h, 7d and 30d shown in the p50 / p90 / p99 columns?" - a: "OpenChainBench's standard leaderboard renders p50 / p90 / p99 of a latency-like metric. For a revenue benchmark there is no native percentile interpretation, so the three slots are repurposed to surface the same metric at three timescales: 24h in p50, 7d in p90, 30d in p99. This preserves the at-a-glance comparability of the leaderboard while making the multi-window nature of the metric explicit. The methodology section calls this out so readers do not misread the columns as actual percentiles." - - q: "What happens to swaps whose tokens have no USD price at swap time?" - a: "They are excluded from the margin sum and the volume sum, and counted separately as relay_swaps_unpriced_total. The success column on the bench page is priced / (priced + unpriced), so a low ratio is visible. Causes of an unpriced swap include long-tail token without a CoinGecko entry, price-feed gap, or a brand-new token launched within minutes of the swap. The published headline understates true volume and margin proportionally to the unpriced share, so a 95 % priced ratio means the headline is about 5 % low." - - q: "Why is the freshness threshold 5 minutes?" - a: "The harness polls Relay's public API every 60 seconds. expected_freshness_seconds is set to 5 minutes which is 5x the poll cadence, so one missed cycle is tolerated and roughly two missed cycles fire the cron alerter. Setting it tighter (e.g. 90 s) would cause spurious alerts on transient API hiccups; setting it looser (e.g. 30 min) would let a multi-cycle outage go unnoticed. 5 min is the standard balance for a 1-min-cadence harness in this repo." - - q: "Could solver rebates make the real number much lower than the headline?" - a: "Yes. If Relay rebates half of every basis point of margin back to the solver that filled the swap, the Relay-only revenue is half the headline. Public disclosures from comparable protocols (e.g. CowSwap, 1inch Fusion) suggest solver-share fractions in the 30 % to 70 % range depending on intent design. The bench does not pick a number for Relay because that would be a guess. Readers building a P&L model should apply their own solver-share assumption to the headline. The headline itself is the ceiling, the floor is open." - - q: "Why is this bench in the Bridges category?" - a: "Relay.link is a cross-chain swap routing protocol, the closest fit in the OpenChainBench category list is Bridges. Other benches in this category measure bridge fees, bridge quote latency and bridge-monitor finality. A Trading or Aggregators classification would also be defensible since Relay is structurally closer to an intent-settlement layer than a classic bridge, but Bridges is the most discoverable bucket for the readers most likely to want this number." - -source: https://github.com/ChainBench/OpenChainBench/blob/main/benchmarks/bridge-revenue.yml -# The Relay harness lives in the private mobula-api/miniapps tree for -# now; this spec self-links until the harness is ported to the public -# OCB repo. Additional bridges (across-revenue, socket-revenue) will -# land as sibling harnesses and feed their own provider entries into -# this same bench spec. - -prometheus: - window: 24h - expected_freshness_seconds: 300 - -# Real metrics emitted by the Relay.link revenue harness: -# relay_revenue_usd{window} gauge (window: 24h | 7d | 30d) -# relay_volume_usd{window} gauge (window: 24h | 7d | 30d) -# relay_swap_count{window} gauge (window: 24h | 7d | 30d) -# relay_take_rate_bps gauge (24h window only, derived margin / volume * 10000) -# relay_swaps_priced_total counter -# relay_swaps_unpriced_total counter -# relay_last_poll_timestamp_seconds gauge -# -# Mapping to leaderboard columns: -# p50 = relay_revenue_usd{window="24h"} (latest 24h window) -# p90 = relay_revenue_usd{window="7d"} (rolling 7d window) -# p99 = relay_revenue_usd{window="30d"} (rolling 30d window) -# mean = relay_revenue_usd{window="7d"} / 7 (daily avg over 7d) -# series = relay_revenue_usd{window="24h"} (chart, evolves over time) -# sample_size = relay_swap_count{window="24h"} -# success = priced / (priced + unpriced) (data-quality ratio) -# -# Note: the p50 / p90 / p99 slots here are repurposed for the 24h / 7d / 30d -# windows because a revenue gauge has no native percentile interpretation. -# This is called out in the methodology and the FAQ. - -providers: - - slug: relay - name: Relay - tag: Cross-chain swap routing protocol, implied margin upper bound - formula: "Sum over the last 24h of (input USD − output USD − gas USD − disclosed external app fees) for every successful swap from Relay.link's public API, an upper bound on Relay+solver margin." - type: relay - secondary: - label: Take rate (24h, bps) - value: "see relay_take_rate_bps" - queries: - p50: relay_revenue_usd{window="24h"} - p90: relay_revenue_usd{window="7d"} - p99: relay_revenue_usd{window="30d"} - mean: relay_revenue_usd{window="7d"} / 7 - success: relay_swaps_priced_total / (relay_swaps_priced_total + relay_swaps_unpriced_total) - sample_size: relay_swap_count{window="24h"} - series: relay_revenue_usd{window="24h"} diff --git a/benchmarks/buyback-audit.md b/benchmarks/buyback-audit.md index 89258898..500605c0 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 b6c7da30..662362e6 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/ChainBench/OpenChainBench/tree/main/harnesses/buyback-audit +source: https://github.com/OpenChainBench/OpenChainBench/tree/main/harnesses/buyback-audit prometheus: window: 7d diff --git a/benchmarks/gas-estimation.yml b/benchmarks/gas-estimation.yml index 44b87c98..26976493 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/ChainBench/OpenChainBench/tree/main/harnesses/gas-estimation +source: https://github.com/OpenChainBench/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 + name: PublicNode feeHistory tag: Thin wrapper over EIP-1559 reward percentiles formula: "Median over 24h of |PublicNode eth_feeHistory p50 reward − realized p50 priority fee| in gwei, computed per next-mined block on the active chain." queries: diff --git a/benchmarks/hyperliquid-frontends.yml b/benchmarks/hyperliquid-frontends.yml deleted file mode 100644 index 9c7eb94d..00000000 --- a/benchmarks/hyperliquid-frontends.yml +++ /dev/null @@ -1,965 +0,0 @@ -# OpenChainBench. Bench № 030 - -slug: hyperliquid-frontends -number: "030" -title: Hyperliquid frontends user-cost benchmark -seo_title: "Hyperliquid frontends 2026: effective builder fee bps, $/user efficiency, fee discipline (Phantom, Axiom, OKX, GMGN, pvp.trade)" -seo_description: "Live benchmark of Hyperliquid frontends ranked by how much they extract from users, not by raw volume. Effective builder fee in bps (cap 100), D7 cohort retention per builder, and USD captured per active trader. Observational, on-chain attribution via builder code field, methodology public." -subtitle: How much each Hyperliquid frontend charges its users via the on-chain builder code. Effective fee in basis points (cap 100), summed over 24h. Lower = more aligned with traders. -category: Trading -status: live -metric: Effective builder fee (24h, bps) -unit: bps -higher_is_better: false - -disclaimer: | - Volume share dashboards already exist (ASXN, Coinmarketman, Flowscan, Allium). OCB leads with user cost instead. Effective fee is a volume weighted `builder_fee / notional` over the rolling 24h, so fee free promo windows pull it down. Native HL UI orders carry no builder code and are excluded; the bench tracks third party frontends that opt into the builder field. Companion metrics surface slippage, freshness, taker share, asset concentration. Local hl-node source, sub minute freshness. - -seo_intro: | - Every Hyperliquid order carries an optional on chain `builder` - field. Frontends like Phantom Perps, Axiom, MetaMask, pvp.trade, - Insilico, DefiApp, Dexari and Okto use this field to claim a - share of taker fees as their own rebate (capped at 100 basis - points on perps, 1000 on spot). The same field is the cleanest - user cost signal available. Dividing the total `builder_fee` - captured by the total notional routed gives the effective fee - each frontend charges its users on top of the base Hyperliquid - fee schedule. This benchmark publishes that ratio per builder - in sub minute freshness, reading the local hl-node fills stream - on the OCB infrastructure rather than the public daily CSV bucket. - The default chart shows the 24h volume weighted effective fee - in basis points. A tab switcher above the chart surfaces five - companion views computed from the same stream. Slippage proxy - is the per fill basis point gap between executed price and the - previous trade on the same asset, averaged over 24h, so a - frontend with a low fee but a wide routing path shows the real - user cost. Time since last fill is an outage detector, seconds - since the most recent attributed fill for that builder. Fills - per minute is the rolling activity rate. Taker share is the - fraction of fills that crossed the spread, separating aggressive - wallet style UIs from pro maker heavy terminals. Volume routed - is the headline number every other dashboard publishes, kept - here as a companion not the ranking driver. - -abstract: | - We compute a family of quality metrics per Hyperliquid builder - code by tailing the fills stream of a local hl-node operated - on OCB infrastructure. The node writes every block of fills - to `node_fills_by_block/hourly/YYYYMMDD/HH`, one JSON object - per line, with the `builder` address and `builderFee` on every - attributed fill. A Go harness tails these files every 30 seconds, - maintains a rolling 24h window in memory, and exposes a stable - Prometheus surface that this page consumes. The headline metric - is effective fee in basis points, `sum(builder_fee_usd) / - sum(notional_usd) * 10000` over the rolling 24h, volume weighted - across every fill attributed to the builder address. Five - companion metrics share the same stream. Price deviation bps is - the per fill `|fill_px - last_seen_px_same_asset| / last_seen_px - * 10000` averaged over the window, a proxy for execution quality. - Time since last fill in seconds catches frontends whose routing - pipeline is down. Fills per minute is the rolling activity rate. - Taker share is `crossed_fills / total_fills`. Top three assets - by routed notional per builder reveals routing strategy and - surfaces HIP-3 perps. The harness keeps a curated `builders.json` - mapping of EVM addresses to frontend slugs, currently 60 entries - covering wallets, terminals, social trading apps, bots, and - HIP-3 perps deployers, seeded from the public DefiLlama - dimension adapter registry plus behavioral cross reference of - the top unidentified addresses. Builder addresses with valid - fills outside this registry route through the same `builder` - field but stay invisible to this leaderboard until added by a - public PR. The bench does not place trades, - does not touch private keys, and does not depend on any internal - Mobula service. - -methodology: - - "Data source. A local hl-node operated on OCB infrastructure tails the Hyperliquid mainnet and writes every block of attributed fills to `node_fills_by_block/hourly/YYYYMMDD/HH`. Each line is one block, the `events` array carries one entry per fill, and every fill with a builder code includes the `builder` address plus `builderFee` in USDC. Sub minute freshness, no dependency on the public daily CSV bucket." - - "Cadence. The Go harness re reads the current and previous hourly files every 30 seconds, parses appended lines, and maintains a rolling 24h window in memory. Prometheus scrapes the metrics every 30 seconds via a Caddy reverse proxy with basic auth. End to end staleness from fill landing on chain to bench page render is typically under 60 seconds." - - "Effective fee calculation. For each builder over the rolling 24h window: `effective_fee_bps = sum(builder_fee_usd) / sum(notional_usd) * 10000`, where `notional_usd = px * sz` summed over every fill. Volume weighted, not flat averaged, so a builder routing one large trade at 1 bps and many tiny trades at 10 bps lands near the rate the biggest dollars actually paid." - - "USD per user efficiency. `fees_per_user_usd = sum(builder_fee_usd) / count(distinct user)` over the rolling 24h window. Surfaces predatory pricing more clearly than effective fee alone. A builder with 5 bps effective fee and $50 per user is extracting differently than one with the same 5 bps and $0.50 per user." - - "Price deviation, slippage proxy. For each new fill on asset A by builder B: `dev_bps = abs(fill_px - last_seen_px_on_A) / last_seen_px * 10000`, where `last_seen_px` is the most recent fill price on that asset across the whole cohort. Averaged per builder over the rolling 24h. Proxy for execution quality. A frontend that routes to less liquid venues or slower order paths shows higher deviation, a direct user cost on top of the fee column." - - "Outage detector. `last_fill_age_seconds = now - max(fill.time for fills in window)` per builder. During active hours the cohort baseline runs under one minute, so anything above a few minutes is a real anomaly. Catches frontends whose routing pipeline is down and frontends that quietly stopped operating but still have their builder code in circulation." - - "Taker share. Each fill has a boolean `crossed` field, true when the fill crossed the spread (taker), false when it rested in the book (maker). `taker_pct = crossed_fills / total_fills` per builder over the window. Separates aggressive instant execution UIs from pro maker heavy terminals." - - "Per asset top three. For each builder, the three assets with the highest notional volume in the rolling 24h. Cardinality bounded at the cohort size times 3 ranks (currently 60 builders × 3 = 180 series). The `asset` label rotates as concentration shifts. Reveals routing strategy and surfaces HIP-3 perps (xyz:, km:, cash: prefixes) that the older Hyperliquid frontends do not list." - - "Builder registry. `miniapps/hyperliquid-frontends-local/builders.json` is a hand curated `[{slug, name, address, valid_from, notes}]` array, currently 60 entries seeded from DefiLlama's open source dimension adapter registry plus a behavioral cross reference pass on the top unidentified addresses. Cohort spans wallets, pro terminals, social and PvP UIs, automated bots, signal apps, and HIP-3 deployers. New addresses ship via public PR." - - "Excluded by design. Native HL UI orders carry no builder code so they are not user attributable by this method. Roughly 95 percent of Hyperliquid fills land without a builder field. The 5 percent that do are the frontend ecosystem this bench measures." - - "Reproducibility. Harness source at `miniapps/hyperliquid-frontends-local/` in the mobula-api repo (Go). Anyone running their own hl-node can clone, point the `-data` flag at their `node_fills_by_block/hourly` root, run the binary against a Prometheus scraper, and reproduce these metrics. No private API keys, no internal Mobula service dependency." - - "Methodology versioning. Any change (registry additions, formula tweaks, exclusion thresholds, window sizes) ships as a public PR. Major changes run a parallel period publishing old and new metric series side by side under a `_v2` suffix until cutover." - -findings: - - "Effective fee spread is 10x across the cohort. Insilico and Axiom both clock 1.00 bps on the last 24h of attributed flow, the cheapest transparent flat in the set. MetaMask sits at 10.00 bps, the practical on-chain cap most frontends respect, making it the most extractive currently tracked. Phantom Perps lands at 5.22, in line with the wallet-integrated tier." - - "Fee alone is half the story. DefiApp executes tightest at 1.90 bps price deviation despite charging 5.02 bps in builder fee. Dexari deviates 25.83 bps, 13x the cohort median of about 3 bps, on top of its 8.91 bps fee. A user paying Dexari to route their flow is paying twice: once at the fee line, once at the fill price." - - "Volume and user-cost are uncorrelated. Phantom Perps routes 19.4M USD in 24h at 5.22 bps. Insilico routes 7.9M USD at 1.00 bps. MetaMask routes 5M USD at 10 bps. The leaderboard most HL dashboards publish (volume share) ranks Phantom first; the leaderboard users actually care about (what each routed dollar pays) ranks Insilico and Axiom on top." - - "Two outage signals are currently firing. Dexari's last attributed fill is 19 minutes stale against a cohort baseline under 1 minute. Okto has zero fills in 24h, fully inactive. The sub-minute hl-node freshness lets the bench distinguish 'no flow yet today' from 'frontend down', a gap the daily CSV pipeline could not close." - - "Taker share separates aggressive UIs from pro terminals. pvp.trade fills 100% taker, Phantom 99.4%, MetaMask 97.9%, all instant execution wallets that cross the spread by default. Insilico sits at 80.6%, the most maker heavy in the cohort, consistent with its workstation positioning where users place resting limit orders rather than market buying." - - "Asset concentration reveals routing strategy. Phantom is 51% BTC weighted (9.9M of 19.4M USD in 24h on BTC alone). DefiApp is the only frontend with HYPE as its #1 asset. Dexari and MetaMask both surface HIP-3 perps in their top three (xyz:TSLA, xyz:CL crude), a feature that does not exist on the older Hyperliquid frontends, useful for traders looking for stock perps exposure." - -faq: - - q: "Why does OpenChainBench not show Hyperliquid volume share?" - a: "Volume share is already published cleanly by ASXN HyperScreener, Coinmarketman HyperTracker, Flowscan, Allium, and at least three Dune dashboards. Republishing it would add no value. The unmeasured part of the frontend question is user cost: how much does each frontend take per dollar routed, and how stable is that take rate. That's what this bench answers. Raw yesterday's UTC notional per builder is still exposed as a secondary metric for readers who want it; it just doesn't drive the headline ranking." - - q: "How is the effective fee actually computed?" - a: "A local hl-node operated on OCB infrastructure tails the Hyperliquid mainnet and writes every block of attributed fills to disk. A Go harness re reads the current hourly file every 30 seconds. For each builder in our registry, we sum `builderFee` (USD) and `px * sz` (notional USD) over every fill in the rolling 24h window, then compute `sum(builder_fee) / sum(notional) * 10000`. That's the volume weighted effective fee in basis points, what a representative dollar of flow paid that frontend. End to end staleness from fill landing on chain to bench page render is typically under 60 seconds." - - q: "What's the on-chain cap?" - a: "Hyperliquid caps builder fees at 100 basis points on perps and 1000 basis points on spot, configured via the `ApproveBuilderFee` action signed by each user's main wallet. The bench's headline is denominated in basis points so 100 = the on-chain cap and 0 = fully waived rebate. Most frontends sit well below the cap; the bench surfaces who sits where." - - q: "Which frontends are tracked?" - a: "The registry currently tracks Phantom Perps, Axiom, pvp.trade, Insilico Terminal, DefiApp, MetaMask, Dexari and Okto. These eight account for roughly half of attributed Hyperliquid volume. Builder addresses were sourced from each frontend's public announcement, cross referenced against CoinMarketMan HyperTracker and Flowscan. Several hundred smaller builder addresses route the remaining attributed volume. They show in our local node stream and are scheduled for inclusion via public PR. The 95 percent of HL flow that uses no builder code (native UI orders, frontends that did not opt into the attribution system) sits outside this bench by design." - - q: "What about builder addresses outside the eight tracked frontends?" - a: "The local hl-node sees every builder address that appears on any Hyperliquid fill. Several hundred smaller builders route attributed volume below the eight tracked here. Those addresses are visible in our raw node stream but stay off the leaderboard until a public PR adds them to `builders.json` with a verified name. The coverage gap is honest, not hidden. Volume share dashboards from ASXN and CoinMarketMan list the full distribution if you need the exhaustive picture today." - - q: "What's fee discipline and why is it useful?" - a: "The 30-day standard deviation of the daily effective fee in basis points. Frontends running rotating promotions (fee-free week → normalize the next week → discount cycle) show high variance even when the 24h headline is low. Frontends with stable transparent pricing show low variance. The discipline column is what tells you whether the headline number you're reading is a stable rate or a snapshot mid-promo." - - q: "Does the bench measure native HL UI orders?" - a: "No. The native HL UI doesn't set a builder code, so its orders are not user-attributable by this method. Native HL's performance is covered separately by `/benchmarks/aggregator-head-lag` (data-feed latency) and the operational metrics Hyperliquid publishes themselves. This bench is specifically about third-party frontends that opt into the builder-code attribution system." - - q: "Can a frontend game the bench by routing wash trades?" - a: "Yes, the bench measures what the chain records. A frontend running wash volume to drive its own fee revenue (or to suppress its effective fee via 0-fee internal flow) shows up exactly as it appears on-chain. Mitigation is structural: the user-count column flags frontends with anomalously low unique-user counts relative to volume (a wash-heavy frontend has high $/user efficiency and low user count). A v1.1 methodology PR will add a sybil-cluster heuristic for repeat-counterparty fills." - - q: "What is price deviation and why publish it alongside the fee?" - a: "Per-fill basis-point gap between executed price and the most recent fill price on the same asset, averaged across the 24h window. Cleanest proxy for execution quality from a frontend that does not publish slippage stats. A frontend can charge a low builder fee and still cost users more if its routing fills wide of the recent print. Fee plus deviation is closer to true user cost than either alone." - - q: "What does the last-fill-age outage signal actually measure?" - a: "Seconds since the most recent attributed fill for that builder. The cohort baseline runs under one minute during active hours, so above a few minutes is a real anomaly. Catches two failure modes: a frontend whose routing pipeline is down (no new fills even when users try to trade), and a frontend that has quietly stopped operating but still has its builder code in circulation. Both were invisible on volume dashboards where zero volume reads as zero, not as broken." - -source: https://github.com/MobulaFi/mobula-monorepo/tree/dev/miniapps/hyperliquid-frontends-local - -prometheus: - window: 24h - expected_freshness_seconds: 5400 - -# Real metrics emitted by the hyperliquid-frontends harness. The Prom labels -# are kept narrow (just `builder`) so the cross-series queries below don't -# explode in cardinality. -# -# hl_frontend_effective_fee_bps_v2{builder} gauge (p50 headline) -# hl_frontend_d7_retention_pct{builder} gauge (p90 column) -# hl_frontend_d30_retention_pct{builder} gauge (also exposed) -# hl_frontend_fees_per_user_usd_v2{builder} gauge (p99 column) -# hl_frontend_volume_usd_24h{builder} gauge (secondary) -# hl_frontend_volume_share_pct{builder} gauge (secondary) -# hl_frontend_users_24h{builder} gauge -# hl_frontend_d7_cohort_size{builder} gauge (sample-size guard) -# hl_frontend_d30_cohort_size{builder} gauge -# hl_frontend_fills_total_24h_v2{builder} gauge -# hl_frontend_unattributed_share_pct gauge (coverage signal) -# hl_frontend_registry_age_seconds gauge (freshness signal) -# hl_frontend_csv_fetch_status_total{builder,code} counter (per HTTP code) -# -# Bench page column mapping. The p50/p90/p99 slots are repurposed since the -# unit is bps and there is no native percentile semantics: p50 = effective -# fee, p90 = D7 cohort retention, p99 = USD fees per unique trader. The -# repurposing is called out in the methodology + FAQ so readers don't -# misread the columns. Fee discipline (30d stddev) is computable in -# Prom via the MCP server but not surfaced in a column. - -providers: - - slug: phantom-perps - name: Phantom - tag: Phantom wallet's HL perps integration - formula: "Volume-weighted effective builder fee in basis points over the last complete UTC day, computed from Hyperliquid's public attributed-fills CSV for Phantom's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="phantom-perps"} - p90: hl_frontend_d7_retention_pct{builder="phantom-perps"} - p99: hl_frontend_fees_per_user_usd_v2{builder="phantom-perps"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="phantom-perps"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="phantom-perps"} - series: hl_frontend_effective_fee_bps_v2{builder="phantom-perps"} - - - slug: axiom - name: Axiom - tag: Solana-native terminal expanding to HL - formula: "Volume-weighted effective builder fee in basis points over the last complete UTC day, computed from Hyperliquid's public attributed-fills CSV for Axiom's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="axiom"} - p90: hl_frontend_d7_retention_pct{builder="axiom"} - p99: hl_frontend_fees_per_user_usd_v2{builder="axiom"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="axiom"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="axiom"} - series: hl_frontend_effective_fee_bps_v2{builder="axiom"} - - - slug: pvp-trade - name: pvp.trade - tag: Social PvP trading rooms - formula: "Volume-weighted effective builder fee in basis points over the last complete UTC day, computed from Hyperliquid's public attributed-fills CSV for pvp.trade's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="pvp-trade"} - p90: hl_frontend_d7_retention_pct{builder="pvp-trade"} - p99: hl_frontend_fees_per_user_usd_v2{builder="pvp-trade"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="pvp-trade"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="pvp-trade"} - series: hl_frontend_effective_fee_bps_v2{builder="pvp-trade"} - - - slug: insilico - name: Insilico - tag: Institutional trading workstation - formula: "Volume-weighted effective builder fee in basis points over the last complete UTC day, computed from Hyperliquid's public attributed-fills CSV for Insilico's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="insilico"} - p90: hl_frontend_d7_retention_pct{builder="insilico"} - p99: hl_frontend_fees_per_user_usd_v2{builder="insilico"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="insilico"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="insilico"} - series: hl_frontend_effective_fee_bps_v2{builder="insilico"} - - - slug: defiapp - name: Defiapp - tag: Defiapp HL frontend - formula: "Volume-weighted effective builder fee in basis points over the last complete UTC day, computed from Hyperliquid's public attributed-fills CSV for Defiapp's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="defiapp"} - p90: hl_frontend_d7_retention_pct{builder="defiapp"} - p99: hl_frontend_fees_per_user_usd_v2{builder="defiapp"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="defiapp"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="defiapp"} - series: hl_frontend_effective_fee_bps_v2{builder="defiapp"} - - - slug: metamask - name: MetaMask - tag: MetaMask wallet HL integration - formula: "Volume-weighted effective builder fee in basis points over the last complete UTC day, computed from Hyperliquid's public attributed-fills CSV for MetaMask's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="metamask"} - p90: hl_frontend_d7_retention_pct{builder="metamask"} - p99: hl_frontend_fees_per_user_usd_v2{builder="metamask"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="metamask"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="metamask"} - series: hl_frontend_effective_fee_bps_v2{builder="metamask"} - - - slug: dexari - name: Dexari - tag: Pro trading UI for HL - formula: "Volume-weighted effective builder fee in basis points over the last complete UTC day, computed from Hyperliquid's public attributed-fills CSV for Dexari's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="dexari"} - p90: hl_frontend_d7_retention_pct{builder="dexari"} - p99: hl_frontend_fees_per_user_usd_v2{builder="dexari"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="dexari"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="dexari"} - series: hl_frontend_effective_fee_bps_v2{builder="dexari"} - - - slug: okto - name: Okto - tag: Okto wallet HL integration - formula: "Volume-weighted effective builder fee in basis points over the last complete UTC day, computed from Hyperliquid's public attributed-fills CSV for Okto's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="okto"} - p90: hl_frontend_d7_retention_pct{builder="okto"} - p99: hl_frontend_fees_per_user_usd_v2{builder="okto"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="okto"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="okto"} - series: hl_frontend_effective_fee_bps_v2{builder="okto"} - - slug: trust-wallet - name: Trust Wallet - tag: Trust Wallet HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Trust Wallet's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="trust-wallet"} - p90: hl_frontend_d7_retention_pct{builder="trust-wallet"} - p99: hl_frontend_fees_per_user_usd_v2{builder="trust-wallet"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="trust-wallet"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="trust-wallet"} - series: hl_frontend_effective_fee_bps_v2{builder="trust-wallet"} - - - slug: sushi - name: Sushi - tag: Sushi HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Sushi's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="sushi"} - p90: hl_frontend_d7_retention_pct{builder="sushi"} - p99: hl_frontend_fees_per_user_usd_v2{builder="sushi"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="sushi"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="sushi"} - series: hl_frontend_effective_fee_bps_v2{builder="sushi"} - - - slug: dreamcash - name: Dreamcash - tag: Dreamcash HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Dreamcash's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="dreamcash"} - p90: hl_frontend_d7_retention_pct{builder="dreamcash"} - p99: hl_frontend_fees_per_user_usd_v2{builder="dreamcash"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="dreamcash"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="dreamcash"} - series: hl_frontend_effective_fee_bps_v2{builder="dreamcash"} - - - slug: based-app - name: Based - tag: Based HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Based's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="based-app"} - p90: hl_frontend_d7_retention_pct{builder="based-app"} - p99: hl_frontend_fees_per_user_usd_v2{builder="based-app"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="based-app"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="based-app"} - series: hl_frontend_effective_fee_bps_v2{builder="based-app"} - - - slug: blink - name: Blink - tag: Blink HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Blink's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="blink"} - p90: hl_frontend_d7_retention_pct{builder="blink"} - p99: hl_frontend_fees_per_user_usd_v2{builder="blink"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="blink"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="blink"} - series: hl_frontend_effective_fee_bps_v2{builder="blink"} - - - slug: perpmate - name: Perpmate - tag: Perpmate HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Perpmate's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="perpmate"} - p90: hl_frontend_d7_retention_pct{builder="perpmate"} - p99: hl_frontend_fees_per_user_usd_v2{builder="perpmate"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="perpmate"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="perpmate"} - series: hl_frontend_effective_fee_bps_v2{builder="perpmate"} - - - slug: arena - name: Arena - tag: Arena HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Arena's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="arena"} - p90: hl_frontend_d7_retention_pct{builder="arena"} - p99: hl_frontend_fees_per_user_usd_v2{builder="arena"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="arena"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="arena"} - series: hl_frontend_effective_fee_bps_v2{builder="arena"} - - - slug: minaraai - name: MinaraAI - tag: MinaraAI HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for MinaraAI's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="minaraai"} - p90: hl_frontend_d7_retention_pct{builder="minaraai"} - p99: hl_frontend_fees_per_user_usd_v2{builder="minaraai"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="minaraai"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="minaraai"} - series: hl_frontend_effective_fee_bps_v2{builder="minaraai"} - - - slug: apexliquid - name: ApexLiquid - tag: ApexLiquid HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for ApexLiquid's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="apexliquid"} - p90: hl_frontend_d7_retention_pct{builder="apexliquid"} - p99: hl_frontend_fees_per_user_usd_v2{builder="apexliquid"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="apexliquid"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="apexliquid"} - series: hl_frontend_effective_fee_bps_v2{builder="apexliquid"} - - - slug: coin98 - name: Coin98 - tag: Coin98 HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Coin98's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="coin98"} - p90: hl_frontend_d7_retention_pct{builder="coin98"} - p99: hl_frontend_fees_per_user_usd_v2{builder="coin98"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="coin98"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="coin98"} - series: hl_frontend_effective_fee_bps_v2{builder="coin98"} - - - slug: coinpilot - name: CoinPilot - tag: CoinPilot HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for CoinPilot's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="coinpilot"} - p90: hl_frontend_d7_retention_pct{builder="coinpilot"} - p99: hl_frontend_fees_per_user_usd_v2{builder="coinpilot"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="coinpilot"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="coinpilot"} - series: hl_frontend_effective_fee_bps_v2{builder="coinpilot"} - - - slug: echosync - name: Echosync - tag: Echosync HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Echosync's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="echosync"} - p90: hl_frontend_d7_retention_pct{builder="echosync"} - p99: hl_frontend_fees_per_user_usd_v2{builder="echosync"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="echosync"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="echosync"} - series: hl_frontend_effective_fee_bps_v2{builder="echosync"} - - - slug: fomo - name: FOMO - tag: FOMO HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for FOMO's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="fomo"} - p90: hl_frontend_d7_retention_pct{builder="fomo"} - p99: hl_frontend_fees_per_user_usd_v2{builder="fomo"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="fomo"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="fomo"} - series: hl_frontend_effective_fee_bps_v2{builder="fomo"} - - - slug: gemwallet - name: Gem Wallet - tag: Gem Wallet HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Gem Wallet's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="gemwallet"} - p90: hl_frontend_d7_retention_pct{builder="gemwallet"} - p99: hl_frontend_fees_per_user_usd_v2{builder="gemwallet"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="gemwallet"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="gemwallet"} - series: hl_frontend_effective_fee_bps_v2{builder="gemwallet"} - - - slug: gtr-trade - name: GTR Trade - tag: GTR Trade HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for GTR Trade's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="gtr-trade"} - p90: hl_frontend_d7_retention_pct{builder="gtr-trade"} - p99: hl_frontend_fees_per_user_usd_v2{builder="gtr-trade"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="gtr-trade"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="gtr-trade"} - series: hl_frontend_effective_fee_bps_v2{builder="gtr-trade"} - - - slug: hyprearn - name: Hyprearn - tag: Hyprearn HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Hyprearn's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="hyprearn"} - p90: hl_frontend_d7_retention_pct{builder="hyprearn"} - p99: hl_frontend_fees_per_user_usd_v2{builder="hyprearn"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="hyprearn"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="hyprearn"} - series: hl_frontend_effective_fee_bps_v2{builder="hyprearn"} - - - slug: legend-trade - name: Legend Trade - tag: Legend Trade HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Legend Trade's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="legend-trade"} - p90: hl_frontend_d7_retention_pct{builder="legend-trade"} - p99: hl_frontend_fees_per_user_usd_v2{builder="legend-trade"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="legend-trade"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="legend-trade"} - series: hl_frontend_effective_fee_bps_v2{builder="legend-trade"} - - - slug: katoshi - name: Katoshi - tag: Katoshi HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Katoshi's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="katoshi"} - p90: hl_frontend_d7_retention_pct{builder="katoshi"} - p99: hl_frontend_fees_per_user_usd_v2{builder="katoshi"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="katoshi"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="katoshi"} - series: hl_frontend_effective_fee_bps_v2{builder="katoshi"} - - - slug: metascalp - name: Metascalp - tag: Metascalp HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Metascalp's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="metascalp"} - p90: hl_frontend_d7_retention_pct{builder="metascalp"} - p99: hl_frontend_fees_per_user_usd_v2{builder="metascalp"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="metascalp"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="metascalp"} - series: hl_frontend_effective_fee_bps_v2{builder="metascalp"} - - - slug: moontrader - name: Moontrader - tag: Moontrader HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Moontrader's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="moontrader"} - p90: hl_frontend_d7_retention_pct{builder="moontrader"} - p99: hl_frontend_fees_per_user_usd_v2{builder="moontrader"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="moontrader"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="moontrader"} - series: hl_frontend_effective_fee_bps_v2{builder="moontrader"} - - - slug: onekey - name: OneKey - tag: OneKey HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for OneKey's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="onekey"} - p90: hl_frontend_d7_retention_pct{builder="onekey"} - p99: hl_frontend_fees_per_user_usd_v2{builder="onekey"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="onekey"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="onekey"} - series: hl_frontend_effective_fee_bps_v2{builder="onekey"} - - - slug: pear - name: Pear - tag: Pear HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Pear's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="pear"} - p90: hl_frontend_d7_retention_pct{builder="pear"} - p99: hl_frontend_fees_per_user_usd_v2{builder="pear"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="pear"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="pear"} - series: hl_frontend_effective_fee_bps_v2{builder="pear"} - - - slug: rabby - name: Rabby - tag: Rabby HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Rabby's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="rabby"} - p90: hl_frontend_d7_retention_pct{builder="rabby"} - p99: hl_frontend_fees_per_user_usd_v2{builder="rabby"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="rabby"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="rabby"} - series: hl_frontend_effective_fee_bps_v2{builder="rabby"} - - - slug: ranger-finance - name: Ranger Finance - tag: Ranger Finance HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Ranger Finance's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="ranger-finance"} - p90: hl_frontend_d7_retention_pct{builder="ranger-finance"} - p99: hl_frontend_fees_per_user_usd_v2{builder="ranger-finance"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="ranger-finance"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="ranger-finance"} - series: hl_frontend_effective_fee_bps_v2{builder="ranger-finance"} - - - slug: senpi - name: Senpi - tag: Senpi HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Senpi's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="senpi"} - p90: hl_frontend_d7_retention_pct{builder="senpi"} - p99: hl_frontend_fees_per_user_usd_v2{builder="senpi"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="senpi"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="senpi"} - series: hl_frontend_effective_fee_bps_v2{builder="senpi"} - - - slug: superx - name: SuperX - tag: SuperX HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for SuperX's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="superx"} - p90: hl_frontend_d7_retention_pct{builder="superx"} - p99: hl_frontend_fees_per_user_usd_v2{builder="superx"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="superx"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="superx"} - series: hl_frontend_effective_fee_bps_v2{builder="superx"} - - - slug: supurr - name: Supurr - tag: Supurr HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Supurr's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="supurr"} - p90: hl_frontend_d7_retention_pct{builder="supurr"} - p99: hl_frontend_fees_per_user_usd_v2{builder="supurr"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="supurr"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="supurr"} - series: hl_frontend_effective_fee_bps_v2{builder="supurr"} - - - slug: unigox - name: Unigox - tag: Unigox HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Unigox's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="unigox"} - p90: hl_frontend_d7_retention_pct{builder="unigox"} - p99: hl_frontend_fees_per_user_usd_v2{builder="unigox"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="unigox"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="unigox"} - series: hl_frontend_effective_fee_bps_v2{builder="unigox"} - - - slug: uxuy - name: UXUY - tag: UXUY HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for UXUY's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="uxuy"} - p90: hl_frontend_d7_retention_pct{builder="uxuy"} - p99: hl_frontend_fees_per_user_usd_v2{builder="uxuy"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="uxuy"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="uxuy"} - series: hl_frontend_effective_fee_bps_v2{builder="uxuy"} - - - slug: wunder - name: Wunder - tag: Wunder HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Wunder's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="wunder"} - p90: hl_frontend_d7_retention_pct{builder="wunder"} - p99: hl_frontend_fees_per_user_usd_v2{builder="wunder"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="wunder"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="wunder"} - series: hl_frontend_effective_fee_bps_v2{builder="wunder"} - - - slug: grider - name: Grider - tag: Grider HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Grider's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="grider"} - p90: hl_frontend_d7_retention_pct{builder="grider"} - p99: hl_frontend_fees_per_user_usd_v2{builder="grider"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="grider"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="grider"} - series: hl_frontend_effective_fee_bps_v2{builder="grider"} - - - slug: tradoor - name: Tradoor - tag: Tradoor HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Tradoor's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="tradoor"} - p90: hl_frontend_d7_retention_pct{builder="tradoor"} - p99: hl_frontend_fees_per_user_usd_v2{builder="tradoor"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="tradoor"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="tradoor"} - series: hl_frontend_effective_fee_bps_v2{builder="tradoor"} - - - slug: bullpenfi - name: BullpenFi - tag: BullpenFi HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for BullpenFi's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="bullpenfi"} - p90: hl_frontend_d7_retention_pct{builder="bullpenfi"} - p99: hl_frontend_fees_per_user_usd_v2{builder="bullpenfi"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="bullpenfi"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="bullpenfi"} - series: hl_frontend_effective_fee_bps_v2{builder="bullpenfi"} - - - slug: dexly-trade - name: Dexly Trade - tag: Dexly Trade HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Dexly Trade's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="dexly-trade"} - p90: hl_frontend_d7_retention_pct{builder="dexly-trade"} - p99: hl_frontend_fees_per_user_usd_v2{builder="dexly-trade"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="dexly-trade"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="dexly-trade"} - series: hl_frontend_effective_fee_bps_v2{builder="dexly-trade"} - - - slug: hyperdash - name: Hyperdash - tag: Hyperdash HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Hyperdash's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="hyperdash"} - p90: hl_frontend_d7_retention_pct{builder="hyperdash"} - p99: hl_frontend_fees_per_user_usd_v2{builder="hyperdash"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="hyperdash"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="hyperdash"} - series: hl_frontend_effective_fee_bps_v2{builder="hyperdash"} - - - slug: infinex - name: Infinex - tag: Infinex HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Infinex's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="infinex"} - p90: hl_frontend_d7_retention_pct{builder="infinex"} - p99: hl_frontend_fees_per_user_usd_v2{builder="infinex"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="infinex"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="infinex"} - series: hl_frontend_effective_fee_bps_v2{builder="infinex"} - - - slug: liminal - name: Liminal - tag: Liminal HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Liminal's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="liminal"} - p90: hl_frontend_d7_retention_pct{builder="liminal"} - p99: hl_frontend_fees_per_user_usd_v2{builder="liminal"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="liminal"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="liminal"} - series: hl_frontend_effective_fee_bps_v2{builder="liminal"} - - - slug: liquid-perps - name: Liquid Perps - tag: Liquid Perps HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Liquid Perps's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="liquid-perps"} - p90: hl_frontend_d7_retention_pct{builder="liquid-perps"} - p99: hl_frontend_fees_per_user_usd_v2{builder="liquid-perps"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="liquid-perps"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="liquid-perps"} - series: hl_frontend_effective_fee_bps_v2{builder="liquid-perps"} - - - slug: lit-trade - name: Lit Trade - tag: Lit Trade HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Lit Trade's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="lit-trade"} - p90: hl_frontend_d7_retention_pct{builder="lit-trade"} - p99: hl_frontend_fees_per_user_usd_v2{builder="lit-trade"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="lit-trade"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="lit-trade"} - series: hl_frontend_effective_fee_bps_v2{builder="lit-trade"} - - - slug: lootbase - name: Lootbase - tag: Lootbase HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Lootbase's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="lootbase"} - p90: hl_frontend_d7_retention_pct{builder="lootbase"} - p99: hl_frontend_fees_per_user_usd_v2{builder="lootbase"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="lootbase"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="lootbase"} - series: hl_frontend_effective_fee_bps_v2{builder="lootbase"} - - - slug: mass-dot-money - name: Mass.money - tag: Mass.money HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Mass.money's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="mass-dot-money"} - p90: hl_frontend_d7_retention_pct{builder="mass-dot-money"} - p99: hl_frontend_fees_per_user_usd_v2{builder="mass-dot-money"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="mass-dot-money"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="mass-dot-money"} - series: hl_frontend_effective_fee_bps_v2{builder="mass-dot-money"} - - - slug: moonbot - name: Moonbot - tag: Moonbot HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Moonbot's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="moonbot"} - p90: hl_frontend_d7_retention_pct{builder="moonbot"} - p99: hl_frontend_fees_per_user_usd_v2{builder="moonbot"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="moonbot"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="moonbot"} - series: hl_frontend_effective_fee_bps_v2{builder="moonbot"} - - - slug: rainbow - name: Rainbow - tag: Rainbow HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Rainbow's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="rainbow"} - p90: hl_frontend_d7_retention_pct{builder="rainbow"} - p99: hl_frontend_fees_per_user_usd_v2{builder="rainbow"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="rainbow"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="rainbow"} - series: hl_frontend_effective_fee_bps_v2{builder="rainbow"} - - - slug: supercexy - name: SuperCEXy - tag: SuperCEXy HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for SuperCEXy's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="supercexy"} - p90: hl_frontend_d7_retention_pct{builder="supercexy"} - p99: hl_frontend_fees_per_user_usd_v2{builder="supercexy"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="supercexy"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="supercexy"} - series: hl_frontend_effective_fee_bps_v2{builder="supercexy"} - - - slug: superstack - name: Superstack - tag: Superstack HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Superstack's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="superstack"} - p90: hl_frontend_d7_retention_pct{builder="superstack"} - p99: hl_frontend_fees_per_user_usd_v2{builder="superstack"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="superstack"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="superstack"} - series: hl_frontend_effective_fee_bps_v2{builder="superstack"} - - - slug: wallet-v - name: Wallet V - tag: Wallet V HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Wallet V's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="wallet-v"} - p90: hl_frontend_d7_retention_pct{builder="wallet-v"} - p99: hl_frontend_fees_per_user_usd_v2{builder="wallet-v"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="wallet-v"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="wallet-v"} - series: hl_frontend_effective_fee_bps_v2{builder="wallet-v"} - - - slug: xtrade-protocol - name: xTrade Protocol - tag: xTrade Protocol HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for xTrade Protocol's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="xtrade-protocol"} - p90: hl_frontend_d7_retention_pct{builder="xtrade-protocol"} - p99: hl_frontend_fees_per_user_usd_v2{builder="xtrade-protocol"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="xtrade-protocol"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="xtrade-protocol"} - series: hl_frontend_effective_fee_bps_v2{builder="xtrade-protocol"} - - - slug: taco-trade - name: Taco Trade - tag: Taco Trade HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Taco Trade's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="taco-trade"} - p90: hl_frontend_d7_retention_pct{builder="taco-trade"} - p99: hl_frontend_fees_per_user_usd_v2{builder="taco-trade"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="taco-trade"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="taco-trade"} - series: hl_frontend_effective_fee_bps_v2{builder="taco-trade"} - - - slug: silhouette - name: Silhouette - tag: Silhouette HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Silhouette's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="silhouette"} - p90: hl_frontend_d7_retention_pct{builder="silhouette"} - p99: hl_frontend_fees_per_user_usd_v2{builder="silhouette"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="silhouette"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="silhouette"} - series: hl_frontend_effective_fee_bps_v2{builder="silhouette"} - - - slug: tread-fi - name: Tread.fi - tag: Tread.fi HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Tread.fi's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="tread-fi"} - p90: hl_frontend_d7_retention_pct{builder="tread-fi"} - p99: hl_frontend_fees_per_user_usd_v2{builder="tread-fi"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="tread-fi"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="tread-fi"} - series: hl_frontend_effective_fee_bps_v2{builder="tread-fi"} - - - slug: flowbot - name: FlowBot - tag: FlowBot HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for FlowBot's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="flowbot"} - p90: hl_frontend_d7_retention_pct{builder="flowbot"} - p99: hl_frontend_fees_per_user_usd_v2{builder="flowbot"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="flowbot"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="flowbot"} - series: hl_frontend_effective_fee_bps_v2{builder="flowbot"} - - - slug: nautilus-trader - name: Nautilus Trader - tag: Nautilus Trader HL frontend - formula: "Volume-weighted effective builder fee in basis points over the rolling 24h window, computed from the local hl-node fills stream for Nautilus Trader's builder address." - queries: - p50: hl_frontend_effective_fee_bps_v2{builder="nautilus-trader"} - p90: hl_frontend_d7_retention_pct{builder="nautilus-trader"} - p99: hl_frontend_fees_per_user_usd_v2{builder="nautilus-trader"} - mean: avg_over_time(hl_frontend_effective_fee_bps_v2{builder="nautilus-trader"}[7d]) - success: (hl_frontend_local_last_tick_unix_v2 > bool (time() - 120)) - sample_size: hl_frontend_fills_total_24h_v2{builder="nautilus-trader"} - series: hl_frontend_effective_fee_bps_v2{builder="nautilus-trader"} - -# Companion metric panels surfaced as a switchable mini leaderboard below -# the main p50/p90/p99 table. Each panel queries one Prom metric per -# provider (builder label) and re-ranks the cohort under its direction. -# All five metrics come from the local hl-node harness and update at the -# same sub-minute cadence as the main headline. -metric_panels: - - id: deviation - label: Slippage proxy - description: "Per fill, the basis-point gap between the executed price and the previous trade price on the same asset, averaged over the rolling 24h window. Lower is better. Frontends that route to less liquid venues or slower order paths show higher deviation, which is direct user cost on top of the fee column." - metric: hl_frontend_price_deviation_bps_v2 - unit: bps - higher_is_better: false - - id: outage - label: Time since last fill - description: "Seconds since this builder's most recent attributed fill landed on Hyperliquid. Cohort baseline runs under one minute during active hours; above a few minutes is a real anomaly. Catches frontends whose routing pipeline is down or quietly stopped operating." - metric: hl_frontend_last_fill_age_seconds_v2 - unit: s - higher_is_better: false - - id: activity - label: Fills per minute - description: "Activity rate over the rolling 24h window. Higher means more flow currently routes through this frontend. Combine with effective fee bps to read the operational intent: high activity + low fee = aligned, high activity + high fee = extractive." - metric: hl_frontend_fills_per_min_v2 - unit: count - higher_is_better: true - - id: taker - label: Taker share - description: "Fraction of fills that crossed the spread (paid the bid ask gap). Aggressive instant execution wallets skew taker heavy, pro terminals with limit order workflows skew maker heavy. Reveals the routing intent embedded in each frontend's UX." - metric: hl_frontend_taker_pct_v2 - unit: pct - higher_is_better: false - - id: volume - label: Volume routed - description: "Notional USD routed in the rolling 24h window. Headline volume metric most other HL dashboards publish. Here it is companion, not the headline, because volume is only loosely correlated with how aligned a frontend is with its users." - metric: hl_frontend_volume_usd_24h_v2 - unit: usd - higher_is_better: true diff --git a/benchmarks/l1-finality.md b/benchmarks/l1-finality.md index 949b0925..57f62ca9 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 to 30; we use the 19-block protocol minimum | +| TRON | Medium | CEX confirmation counts vary 19–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 fda0284b..f29c78d7 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/ChainBench/OpenChainBench/tree/main/harnesses/l1-finality +source: https://github.com/OpenChainBench/OpenChainBench/tree/main/harnesses/l1-finality prometheus: window: 24h @@ -150,7 +150,7 @@ prometheus: providers: - slug: bnb - name: BNB + name: BNB Smart Chain 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 + name: Avalanche C-Chain 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 7be2ad61..57d3b387 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/ChainBench/OpenChainBench/tree/main/harnesses/l2-block-time +source: https://github.com/OpenChainBench/OpenChainBench/tree/main/harnesses/l2-block-time prometheus: window: 24h diff --git a/benchmarks/metadata-coverage.yml b/benchmarks/metadata-coverage.yml index dfc09d12..5ac9d837 100644 --- a/benchmarks/metadata-coverage.yml +++ b/benchmarks/metadata-coverage.yml @@ -24,15 +24,10 @@ 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. 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. + 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. abstract: | We benchmark how complete each aggregator's token-metadata response is for @@ -60,15 +55,14 @@ 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: - - "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." + - "{{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." - "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: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." + - "{{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." -source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/metadata-coverage +source: https://github.com/OpenChainBench/OpenChainBench/tree/main/harnesses/metadata-coverage prometheus: window: 24h @@ -76,7 +70,7 @@ prometheus: faq: - q: "Which token metadata API has the best coverage for fresh launches?" - 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." + 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." - 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 1d7be504..4e1468c5 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: 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." +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." subtitle: Number of blockchains each major onchain data provider officially supports. category: Aggregators status: live @@ -32,24 +32,19 @@ 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 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. + 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. 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." @@ -61,7 +56,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/ChainBench/OpenChainBench/tree/main/harnesses/network-coverage +source: https://github.com/OpenChainBench/OpenChainBench/tree/main/harnesses/network-coverage prometheus: window: 24h @@ -82,9 +77,7 @@ 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"| -# "coinpaprika"|"covalent"| -# "coinstats"|"dune"} +# networks_supported_total{provider="geckoterminal"|"codex"|"mobula"} # -> 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 @@ -129,55 +122,3 @@ 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 deleted file mode 100644 index 6a679cd3..00000000 --- a/benchmarks/network-fees.yml +++ /dev/null @@ -1,334 +0,0 @@ -# OpenChainBench. Bench № 031 - -slug: network-fees -number: "031" -title: Current native transfer fee across L1 and L2 blockchains -seo_title: "Cheapest blockchain transaction fee 2026: L1 and L2 USD live (Ethereum, Arbitrum, Optimism, Base, zkSync, Solana, BNB, Avalanche)" -seo_description: "Live USD cost of a native-token transfer across 20 L1 and L2 chains. L1: Ethereum, Solana, BNB, Avalanche, TRON, Cardano, SUI, TON, Stellar, Litecoin, Monero. L2: Arbitrum, Optimism, Base, zkSync, Linea, Scroll, Blast, Mantle, Taiko. Slow/standard/fast tiers refreshed every 30s." -subtitle: "Current USD cost of moving the chain's native asset across 20 L1 and L2 chains, refreshed every 30 seconds." - -seo_intro: | - This page shows the live USD cost of sending the native token on every major Layer-1 blockchain. We sample the chain-native fee market (eth_feeHistory for EVM chains, getRecentPrioritizationFees for Solana, koios protocol params for Cardano, fee_stats for Stellar, fee_estimate for Monero, mempool oracles for Litecoin, getChainParameters for TRON, sui_getReferenceGasPrice for SUI, hardcoded typical observed for TON), convert to the chain's smallest unit, then multiply by the native-token USD price pulled from Mobula every 30 seconds. The result is a head-to-head comparison of what it actually costs an end user to move one unit of value on each chain, rather than a gas-price comparison in gwei or lamports which is meaningless across protocols. - -faq: - - q: "What does this benchmark measure?" - a: "The current USD cost of a single native-token transfer on each of the 11 tracked L1 chains. A native transfer is the simplest possible action: send the chain's main asset from one address to another. ETH on Ethereum, SOL on Solana, ADA on Cardano, XLM on Stellar, and so on. We do not measure ERC-20 transfers, DEX swaps, or contract calls." - - q: "Why USD instead of gas price?" - a: "Gas price in gwei (Ethereum) cannot be compared to lamports per CU (Solana) or stroops per operation (Stellar). The only honest cross-chain unit is the dollar cost of a user-facing action, computed at scrape time using a live USD price for each native token. Mobula's market API provides the prices we multiply by." - - q: "What do slow, standard, and fast tiers mean?" - a: "Tiers exist for chains with a priority market where users can pay more to get faster inclusion. Slow targets roughly the 25th percentile of recent priority bids, standard the 50th, fast the 90th. Chains with deterministic or near-deterministic fees (Cardano, Stellar, TON, TRON native transfer) emit a single tier because there is no priority market to bid into." - - q: "Why is the Solana fee so low?" - a: "Solana charges 5000 lamports per signature as a hard base, plus an optional priority fee priced in micro-lamports per compute unit. A simple SOL transfer uses around 200 compute units, so the priority component is typically dwarfed by the base. At current SOL prices the headline fee sits well under one cent for non-congested blocks." - - q: "Why is the Cardano fee always similar?" - a: "Cardano fees are deterministic. The protocol parameters (`min_fee_a` per byte, `min_fee_b` base) are set by governance and updated rarely; a standard ADA transfer is roughly 250 bytes, so the lovelace cost is essentially fixed until the next parameter vote. The USD figure on the leaderboard only moves because ADA's USD price moves." - - q: "How are L2 fees handled?" - a: "Not on this page. L2 fees are a separate measurement problem (rollup fee = L2 execution + L1 data posting, the latter being a non-trivial blob market). The L2 bench is in progress as a companion to this L1 page; once shipped, you'll be able to compare L1-fee vs L2-fee for any wallet decision in one click." - - q: "How often does the page refresh?" - a: "Every 30 seconds. The harness re-queries each chain's fee oracle and Mobula's price API every 30 seconds, so headline values are at most 30 seconds stale plus chain RPC latency (typically under a second)." - - q: "Why are some chains using single tier?" - a: "Cardano fees are protocol-deterministic. Stellar's base fee is 100 stroops per operation network-wide. TON's typical fee is a hardcoded conservative estimate because TON has no clean fee-estimate RPC. TRON native transfers consume bandwidth at the published rate per byte. None of these chains expose a priority market a user can bid into for a TRX, ADA, XLM or TON transfer, so emitting a single tier is more honest than fabricating three identical values." - -category: Blockchains -status: live -metric: Native transfer fee USD -unit: usd -higher_is_better: false - -abstract: | - Every 30 seconds we ask each L1 chain its current native-transfer fee - in the chain's smallest unit (wei, lamport, lovelace, stroop, sun, - MIST, nanoton, litoshi, atomic), multiply by the live USD price of - the chain's native token from Mobula's market API, and publish the - result. Chains with a priority market expose three tiers (slow / std - / fast) mapped to roughly the 25th, 50th and 90th percentile of the - recent fee distribution. Deterministic-fee chains emit a single tier. - The 11-chain list mirrors the L1 finality bench so users can compare - cost and speed head-to-head. - -methodology: - - "Refresh cadence: 30 s. One process samples all 11 chains in parallel goroutines." - - "Ethereum / BNB / Avalanche (EVM): `eth_feeHistory` over the last 4 blocks at percentiles [25, 50, 90]. Cost = (base_fee + reward_percentile) × 21_000 gas, mapped to slow/std/fast." - - "Solana: `getRecentPrioritizationFees` percentiles 25/50/90 of micro-lamports per CU × 200 CU + 5_000 lamport base. Empty-fees response collapses to single std tier at the 5_000 base." - - "TRON: `getChainParameters.getTransactionFee` (currently 1_000 SUN/byte) × 268 bytes typical native transfer." - - "Cardano: koios `epoch_params.min_fee_a / min_fee_b` × 250 bytes typical native transfer. Deterministic, refreshes only when on-chain parameters change." - - "Stellar: horizon `fee_stats.last_ledger_base_fee` × 1 operation. Single tier." - - "SUI: `suix_getReferenceGasPrice` × 2_000_000 gas budget (typical observed for `Coin::transfer`). Single std tier." - - "TON: hardcoded 0.005 TON, the typical observed wallet-v4 transfer. TON's fee model is BoC-emulation only; a clean fee-estimate RPC does not exist." - - "Litecoin: litecoinspace.org `/api/v1/fees/recommended` (hourFee / halfHourFee / fastestFee in litoshi/vByte) × 225 vBytes typical 1-in-1-out P2WPKH transfer." - - "Monero: monero-rpc `get_fee_estimate.fees[0..2]` × 1500 bytes typical 1-in-2-out RingCT transaction." - - "USD prices: `api.mobula.io/api/1/market/multi-data` polled every 30 s for all 11 native tokens in one call." - - "Failures: any upstream error leaves the previous gauge in place, increments `tx_fee_fetch_errors_total{chain, error_type}`, and sets `tx_fee_health{chain}=0`." - -findings: - - "{{best_name}} is the cheapest L1 native transfer at {{best_p50}} (std, 24h)." - - "{{name:ethereum}} sits at {{p50:ethereum}} (std, 24h), the most expensive L1 transfer on this leaderboard during normal congestion." - - "{{name:bnb}}, {{name:avalanche}}, {{name:stellar}}, {{name:solana}}, {{name:litecoin}} cluster near or below one cent for a standard transfer." - - "{{name:cardano}}, {{name:tron}}, {{name:monero}} use deterministic or near-deterministic fee models; USD movement on the leaderboard reflects native-token price movement, not network congestion." - - "USD costs are computed at scrape time. A 10% intraday move in the native token's USD price moves the headline by 10% even if the chain-native fee is flat. We surface that intentionally — the dollar cost is what a user actually pays." - -source: https://github.com/MobulaFi/mobula-monorepo/tree/main/miniapps/transaction-fee - -prometheus: - window: 24h - -# Real metrics exposed by the transaction-fee harness: -# tx_fee_native_transfer_usd{chain, tier} gauge (USD) -# tx_fee_native_transfer_native{chain, tier} gauge (smallest unit) -# tx_fee_gas_price_gwei{chain, tier} gauge (EVM only) -# tx_fee_native_token_price_usd{chain} gauge (USD per token) -# tx_fee_last_refresh_timestamp_seconds{chain} gauge (unix s) -# tx_fee_fetch_errors_total{chain, error_type} counter -# tx_fee_health{chain} gauge (0|1) - -providers: - - slug: ethereum - name: Ethereum - layer: l1 - tag: EIP-1559 fee market, 21000 gas × (base + p50 priority) - formula: "Median USD cost of a native ETH transfer over 24h: (base_fee + p50 priority_fee) × 21000 gas × ETH USD price." - queries: - p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="ethereum",tier="std"}[24h]) - p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="ethereum",tier="fast"}[24h]) - p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="ethereum",tier="fast"}[24h]) - mean: avg_over_time(tx_fee_native_transfer_usd{chain="ethereum",tier="std"}[24h]) - success: avg_over_time(tx_fee_health{chain="ethereum"}[24h]) - series: tx_fee_native_transfer_usd{chain="ethereum",tier="std"} - - - slug: bnb - name: BNB Chain - layer: l1 - tag: EIP-1559 fee market, 21000 gas - formula: "Median USD cost of a native BNB transfer over 24h." - queries: - p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="bnb",tier="std"}[24h]) - p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="bnb",tier="fast"}[24h]) - p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="bnb",tier="fast"}[24h]) - mean: avg_over_time(tx_fee_native_transfer_usd{chain="bnb",tier="std"}[24h]) - success: avg_over_time(tx_fee_health{chain="bnb"}[24h]) - series: tx_fee_native_transfer_usd{chain="bnb",tier="std"} - - - slug: avalanche - name: Avalanche - layer: l1 - tag: C-Chain EIP-1559 fee market, 21000 gas - formula: "Median USD cost of a native AVAX transfer over 24h." - queries: - p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="avalanche",tier="std"}[24h]) - p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="avalanche",tier="fast"}[24h]) - p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="avalanche",tier="fast"}[24h]) - mean: avg_over_time(tx_fee_native_transfer_usd{chain="avalanche",tier="std"}[24h]) - success: avg_over_time(tx_fee_health{chain="avalanche"}[24h]) - series: tx_fee_native_transfer_usd{chain="avalanche",tier="std"} - - - slug: solana - name: Solana - layer: l1 - tag: 5000 lamport base + getRecentPrioritizationFees × 200 CU - formula: "Median USD cost of a native SOL transfer over 24h: 5000 lamports + p50 priority × 200 CU × SOL USD price." - queries: - p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="solana",tier="std"}[24h]) - p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="solana",tier="fast"}[24h]) - p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="solana",tier="fast"}[24h]) - mean: avg_over_time(tx_fee_native_transfer_usd{chain="solana",tier="std"}[24h]) - success: avg_over_time(tx_fee_health{chain="solana"}[24h]) - series: tx_fee_native_transfer_usd{chain="solana",tier="std"} - - - slug: tron - name: TRON - layer: l1 - tag: Bandwidth model, 268 bytes × getTransactionFee SUN/byte - formula: "Deterministic USD cost of a native TRX transfer using the bandwidth-rate parameter × 268 bytes typical transfer × TRX USD price." - queries: - p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="tron",tier="single"}[24h]) - p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="tron",tier="single"}[24h]) - p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="tron",tier="single"}[24h]) - mean: avg_over_time(tx_fee_native_transfer_usd{chain="tron",tier="single"}[24h]) - success: avg_over_time(tx_fee_health{chain="tron"}[24h]) - series: tx_fee_native_transfer_usd{chain="tron",tier="single"} - - - slug: cardano - name: Cardano - layer: l1 - tag: min_fee_b + size × min_fee_a, deterministic - formula: "Protocol-deterministic ADA transfer cost: koios live min_fee_a/b × 250 bytes typical × ADA USD price." - queries: - p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="cardano",tier="single"}[24h]) - p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="cardano",tier="single"}[24h]) - p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="cardano",tier="single"}[24h]) - mean: avg_over_time(tx_fee_native_transfer_usd{chain="cardano",tier="single"}[24h]) - success: avg_over_time(tx_fee_health{chain="cardano"}[24h]) - series: tx_fee_native_transfer_usd{chain="cardano",tier="single"} - - - slug: sui - name: Sui - layer: l1 - tag: suix_getReferenceGasPrice × 2_000_000 gas budget - formula: "USD cost of a native SUI transfer: live reference gas price × 2M gas budget × SUI USD price." - queries: - p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="sui",tier="std"}[24h]) - p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="sui",tier="std"}[24h]) - p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="sui",tier="std"}[24h]) - mean: avg_over_time(tx_fee_native_transfer_usd{chain="sui",tier="std"}[24h]) - success: avg_over_time(tx_fee_health{chain="sui"}[24h]) - series: tx_fee_native_transfer_usd{chain="sui",tier="std"} - - - slug: ton - name: TON - layer: l1 - tag: Hardcoded 0.005 TON typical wallet-v4 transfer - formula: "Conservative typical-observed cost of a TON wallet transfer × TON USD price. TON has no fee-estimate RPC; this is the observed median." - queries: - p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="ton",tier="single"}[24h]) - p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="ton",tier="single"}[24h]) - p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="ton",tier="single"}[24h]) - mean: avg_over_time(tx_fee_native_transfer_usd{chain="ton",tier="single"}[24h]) - success: avg_over_time(tx_fee_health{chain="ton"}[24h]) - series: tx_fee_native_transfer_usd{chain="ton",tier="single"} - - - slug: stellar - name: Stellar - layer: l1 - tag: 100 stroops base per operation, surge during congestion - formula: "Live horizon fee_stats.last_ledger_base_fee × 1 op × XLM USD price." - queries: - p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="stellar",tier="single"}[24h]) - p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="stellar",tier="single"}[24h]) - p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="stellar",tier="single"}[24h]) - mean: avg_over_time(tx_fee_native_transfer_usd{chain="stellar",tier="single"}[24h]) - success: avg_over_time(tx_fee_health{chain="stellar"}[24h]) - series: tx_fee_native_transfer_usd{chain="stellar",tier="single"} - - - slug: litecoin - name: Litecoin - layer: l1 - tag: litecoinspace fee oracle × 225 vBytes - formula: "Mempool-based litoshi/vByte × 225 vBytes typical P2WPKH transfer × LTC USD price." - queries: - p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="litecoin",tier="std"}[24h]) - p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="litecoin",tier="fast"}[24h]) - p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="litecoin",tier="fast"}[24h]) - mean: avg_over_time(tx_fee_native_transfer_usd{chain="litecoin",tier="std"}[24h]) - success: avg_over_time(tx_fee_health{chain="litecoin"}[24h]) - series: tx_fee_native_transfer_usd{chain="litecoin",tier="std"} - - - slug: monero - name: Monero - layer: l1 - tag: get_fee_estimate × 1500 bytes RingCT - formula: "Monero-rpc fee per byte × 1500 bytes typical 1-in-2-out RingCT × XMR USD price." - queries: - p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="monero",tier="std"}[24h]) - p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="monero",tier="fast"}[24h]) - p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="monero",tier="fast"}[24h]) - mean: avg_over_time(tx_fee_native_transfer_usd{chain="monero",tier="std"}[24h]) - success: avg_over_time(tx_fee_health{chain="monero"}[24h]) - series: tx_fee_native_transfer_usd{chain="monero",tier="std"} - - - slug: arbitrum - name: Arbitrum One - layer: l2 - tag: "Optimistic rollup (Nitro), 21000 gas" - formula: "Median USD cost of a native ETH transfer on Arbitrum One over 24h. L2 execution cost only; L1 data posting fee excluded (Phase 2)." - queries: - p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="arbitrum",tier="std"}[24h]) - p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="arbitrum",tier="fast"}[24h]) - p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="arbitrum",tier="fast"}[24h]) - mean: avg_over_time(tx_fee_native_transfer_usd{chain="arbitrum",tier="std"}[24h]) - success: avg_over_time(tx_fee_health{chain="arbitrum"}[24h]) - series: tx_fee_native_transfer_usd{chain="arbitrum",tier="std"} - - slug: optimism - name: Optimism - layer: l2 - tag: "OP Stack optimistic rollup, 21000 gas" - formula: "Median USD cost of a native ETH transfer on Optimism over 24h. L2 execution cost only; L1 data posting fee excluded (Phase 2)." - queries: - p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="optimism",tier="std"}[24h]) - p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="optimism",tier="fast"}[24h]) - p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="optimism",tier="fast"}[24h]) - mean: avg_over_time(tx_fee_native_transfer_usd{chain="optimism",tier="std"}[24h]) - success: avg_over_time(tx_fee_health{chain="optimism"}[24h]) - series: tx_fee_native_transfer_usd{chain="optimism",tier="std"} - - slug: base - name: Base - layer: l2 - tag: "OP Stack optimistic rollup (Coinbase), 21000 gas" - formula: "Median USD cost of a native ETH transfer on Base over 24h. L2 execution cost only; L1 data posting fee excluded (Phase 2)." - queries: - p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="base",tier="std"}[24h]) - p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="base",tier="fast"}[24h]) - p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="base",tier="fast"}[24h]) - mean: avg_over_time(tx_fee_native_transfer_usd{chain="base",tier="std"}[24h]) - success: avg_over_time(tx_fee_health{chain="base"}[24h]) - series: tx_fee_native_transfer_usd{chain="base",tier="std"} - - slug: zksync - name: zkSync Era - layer: l2 - tag: "ZK rollup, 21000 gas" - formula: "Median USD cost of a native ETH transfer on zkSync Era over 24h. L2 execution cost only; L1 data posting fee excluded (Phase 2)." - queries: - p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="zksync",tier="std"}[24h]) - p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="zksync",tier="fast"}[24h]) - p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="zksync",tier="fast"}[24h]) - mean: avg_over_time(tx_fee_native_transfer_usd{chain="zksync",tier="std"}[24h]) - success: avg_over_time(tx_fee_health{chain="zksync"}[24h]) - series: tx_fee_native_transfer_usd{chain="zksync",tier="std"} - - slug: linea - name: Linea - layer: l2 - tag: "ZK rollup (Stark), 21000 gas" - formula: "Median USD cost of a native ETH transfer on Linea over 24h. L2 execution cost only; L1 data posting fee excluded (Phase 2)." - queries: - p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="linea",tier="std"}[24h]) - p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="linea",tier="fast"}[24h]) - p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="linea",tier="fast"}[24h]) - mean: avg_over_time(tx_fee_native_transfer_usd{chain="linea",tier="std"}[24h]) - success: avg_over_time(tx_fee_health{chain="linea"}[24h]) - series: tx_fee_native_transfer_usd{chain="linea",tier="std"} - - slug: scroll - name: Scroll - layer: l2 - tag: "zkEVM rollup, 21000 gas" - formula: "Median USD cost of a native ETH transfer on Scroll over 24h. L2 execution cost only; L1 data posting fee excluded (Phase 2)." - queries: - p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="scroll",tier="std"}[24h]) - p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="scroll",tier="fast"}[24h]) - p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="scroll",tier="fast"}[24h]) - mean: avg_over_time(tx_fee_native_transfer_usd{chain="scroll",tier="std"}[24h]) - success: avg_over_time(tx_fee_health{chain="scroll"}[24h]) - series: tx_fee_native_transfer_usd{chain="scroll",tier="std"} - - slug: blast - name: Blast - layer: l2 - tag: "OP Stack optimistic rollup (Blast), 21000 gas" - formula: "Median USD cost of a native ETH transfer on Blast over 24h. L2 execution cost only; L1 data posting fee excluded (Phase 2)." - queries: - p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="blast",tier="std"}[24h]) - p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="blast",tier="fast"}[24h]) - p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="blast",tier="fast"}[24h]) - mean: avg_over_time(tx_fee_native_transfer_usd{chain="blast",tier="std"}[24h]) - success: avg_over_time(tx_fee_health{chain="blast"}[24h]) - series: tx_fee_native_transfer_usd{chain="blast",tier="std"} - - slug: mantle - name: Mantle - layer: l2 - tag: "OP Stack optimistic rollup (Mantle), 21000 gas" - formula: "Median USD cost of a native ETH transfer on Mantle over 24h. L2 execution cost only; L1 data posting fee excluded (Phase 2)." - queries: - p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="mantle",tier="std"}[24h]) - p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="mantle",tier="fast"}[24h]) - p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="mantle",tier="fast"}[24h]) - mean: avg_over_time(tx_fee_native_transfer_usd{chain="mantle",tier="std"}[24h]) - success: avg_over_time(tx_fee_health{chain="mantle"}[24h]) - series: tx_fee_native_transfer_usd{chain="mantle",tier="std"} - - slug: taiko - name: Taiko - layer: l2 - tag: "Based rollup, 21000 gas" - formula: "Median USD cost of a native ETH transfer on Taiko over 24h. L2 execution cost only; L1 data posting fee excluded (Phase 2)." - queries: - p50: quantile_over_time(0.50, tx_fee_native_transfer_usd{chain="taiko",tier="std"}[24h]) - p90: quantile_over_time(0.90, tx_fee_native_transfer_usd{chain="taiko",tier="fast"}[24h]) - p99: quantile_over_time(0.99, tx_fee_native_transfer_usd{chain="taiko",tier="fast"}[24h]) - mean: avg_over_time(tx_fee_native_transfer_usd{chain="taiko",tier="std"}[24h]) - success: avg_over_time(tx_fee_health{chain="taiko"}[24h]) - series: tx_fee_native_transfer_usd{chain="taiko",tier="std"} diff --git a/benchmarks/oracle-deviation.yml b/benchmarks/oracle-deviation.yml index 07c91167..9942131c 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/ChainBench/OpenChainBench/tree/main/harnesses/oracle-deviation +source: https://github.com/OpenChainBench/OpenChainBench/tree/main/harnesses/oracle-deviation prometheus: window: 24h diff --git a/benchmarks/perp-fees.yml b/benchmarks/perp-fees.yml index 2dd5f364..9c43e260 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/ChainBench/OpenChainBench/tree/main/harnesses/perp-fees +source: https://github.com/OpenChainBench/OpenChainBench/tree/main/harnesses/perp-fees prometheus: window: 24h diff --git a/benchmarks/rpc-capabilities.yml b/benchmarks/rpc-capabilities.yml index 5a305519..70f2f578 100644 --- a/benchmarks/rpc-capabilities.yml +++ b/benchmarks/rpc-capabilities.yml @@ -22,25 +22,19 @@ 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 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. + 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. abstract: | We measure the round-trip latency of a single, identical RPC call @@ -60,7 +54,7 @@ abstract: | as the fastest. methodology: - - "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." + - "Cadence: every 15 seconds per (provider, chain) pair. Single eu-west region; multi-region requires running additional monitor instances." - "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." @@ -69,18 +63,16 @@ 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: - - "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." + - "{{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." faq: - q: "What is the fastest free public RPC right now?" - 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." + 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." - 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?" @@ -88,11 +80,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 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." + 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." - 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/ChainBench/OpenChainBench/tree/main/harnesses/rpc-capabilities +source: https://github.com/OpenChainBench/OpenChainBench/tree/main/harnesses/rpc-capabilities prometheus: window: 24h @@ -113,16 +105,6 @@ 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 @@ -141,344 +123,194 @@ providers: - slug: publicnode name: PublicNode tag: Allnodes-operated, 70+ chains, archive on most - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to PublicNode's no-key endpoint." + 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." queries: - 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])) + 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]) 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(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]) + series: avg_over_time(rpc_latency_milliseconds{provider="publicnode"}[1h]) - slug: drpc name: dRPC tag: Decentralized RPC mesh, consensus-checked - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to dRPC's decentralized mesh." + 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." queries: - 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])) + 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]) 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(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]) + series: avg_over_time(rpc_latency_milliseconds{provider="drpc"}[1h]) - slug: 1rpc name: 1RPC tag: Privacy-preserving gateway by Automata Network - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to 1RPC's Automata gateway." + 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." queries: - 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])) + 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]) 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(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]) + series: avg_over_time(rpc_latency_milliseconds{provider="1rpc"}[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 3 regions (us-east + eu-west + sgp) 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 eu-west to MeowRPC's no-key endpoint." queries: - 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])) + 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]) 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(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]) + series: avg_over_time(rpc_latency_milliseconds{provider="meowrpc"}[1h]) - slug: flashbots - name: Flashbots + name: Flashbots Protect 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 3 regions (us-east + eu-west + sgp) 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 eu-west to the Flashbots Protect read proxy." queries: - 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])) + 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]) 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(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]) + series: avg_over_time(rpc_latency_milliseconds{provider="flashbots"}[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 3 regions (us-east + eu-west + sgp) 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 eu-west to cloudflare-eth (check success column for jsonrpc_err share)." queries: - 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])) + 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]) 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(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]) + series: avg_over_time(rpc_latency_milliseconds{provider="cloudflare"}[1h]) - slug: base-official - name: Base + name: Base Official 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 3 regions (us-east + eu-west + sgp) 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 eu-west to Coinbase's `mainnet.base.org` endpoint." queries: - 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])) + 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]) 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(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]) + series: avg_over_time(rpc_latency_milliseconds{provider="base-official"}[1h]) - slug: binance - name: Binance + name: Binance Official 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 3 regions (us-east + eu-west + sgp) 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 eu-west to Binance's `bsc-dataseed1.binance.org` endpoint." queries: - 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])) + 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]) 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(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]) + series: avg_over_time(rpc_latency_milliseconds{provider="binance"}[1h]) - slug: tenderly - name: Tenderly + name: Tenderly Gateway 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 3 regions (us-east + eu-west + sgp) 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 eu-west to `gateway.tenderly.co/public/`." queries: - 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])) + 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]) 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(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]) + series: avg_over_time(rpc_latency_milliseconds{provider="tenderly"}[1h]) - slug: nodies - name: Nodies + name: Nodies (POKT) tag: POKT Network's decentralized public RPC successor, 7+ chains - formula: "50th percentile over 24h of client-side round-trip latency (ms) for a single `eth_blockNumber` POST sent every 15s from 3 regions (us-east + eu-west + sgp) to Nodies' `*-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 eu-west to Nodies' `*-pokt.nodies.app` endpoint." queries: - 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])) + 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]) 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(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]) + series: avg_over_time(rpc_latency_milliseconds{provider="nodies"}[1h]) - slug: lava - name: Lava + name: Lava Network 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 3 regions (us-east + eu-west + sgp) 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 eu-west to Lava Network's no-key mesh endpoint." queries: - 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])) + 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]) 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(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]) + series: avg_over_time(rpc_latency_milliseconds{provider="lava"}[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 3 regions (us-east + eu-west + sgp) 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 eu-west to Merkle's Base/BSC no-key gateway." queries: - 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])) + 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]) 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(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]) + series: avg_over_time(rpc_latency_milliseconds{provider="merkle"}[1h]) - slug: arbitrum-official - name: Arbitrum + name: Arbitrum Official 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 3 regions (us-east + eu-west + sgp) 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 eu-west to the Arbitrum Foundation's `arb1.arbitrum.io/rpc`." queries: - 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])) + 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]) 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(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]) + series: avg_over_time(rpc_latency_milliseconds{provider="arbitrum-official"}[1h]) - slug: optimism-official - name: Optimism + name: Optimism Official 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 3 regions (us-east + eu-west + sgp) 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 eu-west to the Optimism Foundation's `mainnet.optimism.io`." queries: - 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])) + 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]) 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(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]) + series: avg_over_time(rpc_latency_milliseconds{provider="optimism-official"}[1h]) - slug: avalanche-official - name: Avalanche + name: Avalanche Official 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 3 regions (us-east + eu-west + sgp) 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 eu-west to Ava Labs' `api.avax.network/ext/bc/C/rpc`." queries: - 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])) + 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]) 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(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]) + series: avg_over_time(rpc_latency_milliseconds{provider="avalanche-official"}[1h]) diff --git a/benchmarks/solana-tx-landing.yml b/benchmarks/solana-tx-landing.yml index 567e2dd7..19fbbac0 100644 --- a/benchmarks/solana-tx-landing.yml +++ b/benchmarks/solana-tx-landing.yml @@ -92,7 +92,7 @@ faq: - q: "What about Syncro Sender and Slipstream?" a: "Syncro Sender (P2P.org) uses per-customer tip wallets that aren't publicly documented, every customer gets a private address. We can't enumerate them, so Syncro flow is invisible to this bench. Estimated share: small (Syncro launched in 2025 and hasn't disclosed adoption metrics). Slipstream is a router that calls the underlying senders' SDKs, its tx land via Jito/Nozomi/0slot tip wallets and are attributed to those services. So Slipstream-originated flow shows up correctly counted, just under the underlying service's name. If you specifically want to know 'what % uses Slipstream as the router', this bench can't answer that, Slipstream itself doesn't add an identifying memo." -source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/solana-tx-landing +source: https://github.com/OpenChainBench/OpenChainBench/tree/main/harnesses/solana-tx-landing prometheus: window: 24h @@ -125,7 +125,7 @@ providers: series: sum(rate(solana_landing_tx_total{service="jito"}[5m])) - slug: helius-sender - name: Helius + 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: @@ -151,7 +151,7 @@ providers: series: sum(rate(solana_landing_tx_total{service="nozomi"}[5m])) - slug: bloxroute - name: 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: @@ -164,7 +164,7 @@ providers: series: sum(rate(solana_landing_tx_total{service="bloxroute"}[5m])) - slug: 0slot - name: 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: @@ -190,7 +190,7 @@ providers: series: sum(rate(solana_landing_tx_total{service="nextblock"}[5m])) - slug: astralane - name: 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: diff --git a/benchmarks/stablecoin-peg-usdt-anchored.yml b/benchmarks/stablecoin-peg-usdt-anchored.yml index bf74d2c1..6ebb421a 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/ChainBench/OpenChainBench/tree/main/harnesses/stablecoin-peg +source: https://github.com/OpenChainBench/OpenChainBench/tree/main/harnesses/stablecoin-peg prometheus: window: 24h diff --git a/benchmarks/stablecoin-peg.yml b/benchmarks/stablecoin-peg.yml index e92ed949..6f4bd736 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/ChainBench/OpenChainBench/tree/main/harnesses/stablecoin-peg +source: https://github.com/OpenChainBench/OpenChainBench/tree/main/harnesses/stablecoin-peg prometheus: window: 24h diff --git a/benchmarks/validator-yield.yml b/benchmarks/validator-yield.yml index 4099e4e6..4cb09cd5 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/ChainBench/OpenChainBench/tree/main/harnesses/validator-yield +source: https://github.com/OpenChainBench/OpenChainBench/tree/main/harnesses/validator-yield prometheus: window: 24h diff --git a/benchmarks/wallet-labels-coverage.yml b/benchmarks/wallet-labels-coverage.yml index d6d95d3d..930fcfd5 100644 --- a/benchmarks/wallet-labels-coverage.yml +++ b/benchmarks/wallet-labels-coverage.yml @@ -67,7 +67,7 @@ faq: - q: "What does 'wallet labeling' mean in a crypto API?" a: "A wallet labeling API takes an address and returns an entity name. The CEX it belongs to (`Binance hot wallet 14`), the protocol (`Uniswap V3 router`), the multisig owner (`Safe: foundation treasury`), the sanctioned status (`OFAC SDN`), or a public-figure tag (`Vitalik Buterin`). Generic categorical labels like `EOA`, `Contract`, `Wallet` are not considered a hit on this benchmark because they carry no entity signal." - q: "Is Mobula's labels API better than Moralis or Helius?" - a: "It depends on the chain. {{name:mobula}} is a universal provider audited on every chain it advertises and currently returns {{p50:mobula}} on the active tab. {{name:helius}} is Solana-only and dominates that chain because its label graph is curated against native Solana programs. {{name:moralis}} is EVM-centric and trails on TON, Stellar, XRP and Bitcoin. The per-chain tabs are the honest comparison." + 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?" @@ -75,7 +75,7 @@ faq: - q: "Which wallet labeling API has the best Solana coverage?" a: "{{name:helius}} returns {{p50:helius}} on Solana. Chain-specialists dominate their home chain because their label graph is curated against native protocols (Jito stake pools, Jupiter routers, Pump.fun creators, Magic Eden marketplace programs) rather than translated from an EVM-shaped schema. Universal providers (Mobula, Moralis) are also audited on Solana, but switch the chain tab to Solana to see the direct comparison rather than the cross-chain average." -source: https://github.com/ChainBench/OpenChainBench/tree/main/harnesses/wallet-labels +source: https://github.com/OpenChainBench/OpenChainBench/tree/main/harnesses/wallet-labels prometheus: window: 24h @@ -86,6 +86,7 @@ prometheus: # value `all` skips the filter, aggregate over every chain at once. dimensions: chain: + - { value: all, label: All chains } - { value: ethereum, label: Ethereum } - { value: solana, label: Solana } - { value: bnb, label: BNB Chain } diff --git a/docs/methodology/bridge-revenue.md b/docs/methodology/bridge-revenue.md deleted file mode 100644 index 4232f934..00000000 --- a/docs/methodology/bridge-revenue.md +++ /dev/null @@ -1,151 +0,0 @@ -# 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 (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. - ---- - -## 1. Question we answer - -For Relay.link, the cross-chain swap routing protocol, **what is the smallest upper bound on the protocol revenue retained by Relay plus its solver network**, computed directly from the public swap stream over rolling 24-hour, 7-day, and 30-day windows. - -The bench does **not** answer "what is Relay's bottom-line revenue net of solver share". That requires private rebate-accounting data Relay has never published. The bench answers "what is the ceiling, computed from the public data, on what Relay plus solvers collectively retain". Apply your own solver-share assumption to the headline if you need a Relay-only number. - -## 2. Scope - -| Dimension | Value | -|---|---| -| Protocol measured | Relay.link (single, no comparison set) | -| Data source | Relay.link public swap API | -| Poll cadence | 60 s | -| Windows | 24h, 7d, 30d (rolling) | -| USD reference | CoinGecko or equivalent at swap timestamp | -| Confidence | Status = `success` swaps only; pending / failed excluded | - -Adding a comparison set (Across, Stargate, deBridge, etc.) would require each protocol to expose a similarly detailed swap API. As of v1.0, Relay is the only protocol in the bench. The page is deliberately framed as a single-protocol revenue tracker, not a comparative leaderboard. - -## 3. Margin definition (exact) - -For each successful swap: - -``` -per_swap_margin_usd = - input_usd - - output_usd - - gas_usd - - external_app_fees_usd -``` - -Where: - -- `input_usd` = `input_token_amount * usd_price(input_token, swap_timestamp)` -- `output_usd` = `output_token_amount * usd_price(output_token, swap_timestamp)` -- `gas_usd` = `gas_used * gas_price * usd_price(chain_native_token, swap_timestamp)` summed across every chain leg the swap touches -- `external_app_fees_usd` = sum of any frontend / referrer fees the swap payload discloses - -USD price is read at the swap timestamp, not at poll time, so a price move between swap and poll does not bias the margin. - -## 4. Window aggregation - -`relay_revenue_usd{window="24h"}` = sum of `per_swap_margin_usd` across every successful, priced swap completed in the last 24 hours. - -Same for `window="7d"` and `window="30d"`. The windows are rolling, not calendar-aligned, so the 24h sum at time T covers (T - 24h, T]. - -`relay_volume_usd` and `relay_swap_count` follow the same window semantics. - -`relay_take_rate_bps` = `(relay_revenue_usd{window="24h"} / relay_volume_usd{window="24h"}) * 10000`. Reported on the 24-hour window as the most recent operational signal. The 7d and 30d versions are derivable from the published gauges but not surfaced as their own gauge in v1.0. - -## 5. Excluded swaps - -- **Status not `success`.** Pending or failed swaps are not counted. They are tracked separately by the harness for operational visibility but do not appear in the margin sum. -- **Unpriced legs.** If any token in the swap (input, output, or gas-token leg) has no USD reference at swap time, the swap is excluded from the margin and volume sums and incremented into `relay_swaps_unpriced_total`. Successfully priced swaps go into `relay_swaps_priced_total`. The success column on the bench page is `priced / (priced + unpriced)`, a data-quality ratio. - -The harness logs every excluded swap with reason so methodology audits can verify the exclusion rate is reasonable. - -## 6. Why margin is an upper bound on Relay revenue - -Relay shares revenue with its solver network through arrangements the public API does not break out. Per-swap margin captures what Relay AND its solver network collectively retain. Without access to internal rebate accounting, the bench cannot decompose the split. The headline is therefore the smallest upper bound consistent with the public data; the lower bound is open. - -Public disclosures from comparable intent / settlement protocols (CowSwap, 1inch Fusion, UniswapX) suggest solver-share fractions in the 30 % to 70 % range depending on intent design. A reader building a Relay P&L model should pick a number in that range and apply it to the headline as `relay_only_revenue ≈ headline * (1 - solver_share)`. - -## 7. Cross-validation expected band - -Public disclosures (Relay public communications, on-chain volume trackers, comparable cross-chain protocol disclosures) put Relay daily volume at **$80M to $150M per day**. - -Industry-standard cross-chain take rates cluster at **1 to 5 basis points blended**. - -Multiplying the two ranges yields an expected protocol-revenue band of **$20k to $100k per day**, or **$0.75M to $2.5M per month**. - -The live bench number is expected to fall inside this band. Meaningful deviation is either: -1. A data-quality issue (priced-swap ratio dropping, gas-estimation drift, undisclosed app fees rising, API change), -2. A regime change in Relay's volume or take rate worth investigating, -3. An error in the cross-validation assumptions that needs documenting. - -The methodology section flags case 1 via the priced ratio and last-poll-timestamp gauges. Cases 2 and 3 are open research questions resolved through GitHub issues. - -## 8. Metrics (Prometheus, exposed at `:2112/metrics`) - -``` -relay_revenue_usd{window} gauge # window: 24h | 7d | 30d -relay_volume_usd{window} gauge # window: 24h | 7d | 30d -relay_swap_count{window} gauge # window: 24h | 7d | 30d -relay_take_rate_bps gauge # 24h window only -relay_swaps_priced_total counter # cumulative since harness start -relay_swaps_unpriced_total counter # cumulative since harness start -relay_last_poll_timestamp_seconds gauge # Unix timestamp of last successful poll -``` - -Label semantics: -- `window` ∈ {24h, 7d, 30d} - -## 9. Bench page column mapping - -The OpenChainBench leaderboard convention is p50 / p90 / p99 of a latency-like metric. For a revenue gauge there is no native percentile interpretation, so the three slots are repurposed: - -- `p50` ← `relay_revenue_usd{window="24h"}` (latest 24h window) -- `p90` ← `relay_revenue_usd{window="7d"}` (rolling 7d sum) -- `p99` ← `relay_revenue_usd{window="30d"}` (rolling 30d sum) -- `mean` ← `relay_revenue_usd{window="7d"} / 7` (daily average over 7d) -- `series` ← `relay_revenue_usd{window="24h"}` (chart, evolves over time) -- `sample_size` ← `relay_swap_count{window="24h"}` -- `success` ← `relay_swaps_priced_total / (relay_swaps_priced_total + relay_swaps_unpriced_total)` (data-quality ratio, not service-uptime ratio) - -This mapping is explicit on the bench page FAQ so readers do not misread the columns as actual percentiles. - -## 10. Versioning rules - -1. **Any** change to §3 (margin definition), §4 (window aggregation), §5 (excluded swaps), or §8 (metric names) ships as a public PR against this file with a 14-day comment window. -2. Adding a comparison protocol (e.g. Across, Stargate) requires a PR that extends §2 + §8 to cover the new protocol's swap source and is announced on the OpenChainBench blog with a 14-day window. -3. Pricing-source changes (CoinGecko to a different feed) require a PR with a side-by-side reconciliation showing the headline delta across at least one rolling 7-day window. -4. Solver-share decomposition (moving from upper-bound headline to a "Relay only" net headline) is a major version bump (v1 → v2) with a 30-day shadow-run period publishing both old and new metric series in parallel. - -## 11. Reproducibility - -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. - -## 12. Limitations (explicit) - -- **Upper bound only.** The headline is what Relay plus solvers collectively retain, not Relay's own revenue net of solver rebates. See §6. -- **Unpriced swaps reduce coverage.** Long-tail tokens without a USD reference at swap time drop out of the margin sum. The priced ratio (`success` column) makes this visible; readers should mentally scale the headline by `1 / priced_ratio` for an order-of-magnitude correction if needed. -- **Gas estimation drift.** `gas_used * gas_price` is read from the chain, but the USD conversion uses the swap-time native-token price, which can lag on volatile minutes. The error is bounded by the chain-native-token volatility over the swap interval (typically seconds) and is small relative to the per-swap margin. -- **External app fees only as disclosed.** If a frontend takes an undisclosed cut before the swap reaches Relay's API, that cut is not subtracted and the headline overestimates Relay+solver retention. The bench cannot see undisclosed fees by definition. -- **No comparison set in v1.0.** This is a single-protocol tracker. A future PR may add comparable cross-chain protocols once their public APIs expose enough detail to replicate the methodology. -- **Status field trust.** The bench trusts the Relay API's `status = "success"` field. A swap reported as success that actually failed on chain would be miscounted. The harness does not currently double-check status against on-chain state, this is a planned v1.1 hardening. - -## 13. Sponsor independence - -Sponsorship contracts (when present) follow the standard OpenChainBench public template: - -- Sponsors fund operations and receive newsletter visibility, case studies, integration support. Never methodology influence, advance results, or selective publication. -- The non-suppression clause grants the sponsor a single remedy for unfavorable results: terminate the agreement and receive a pro-rata refund. Never edit, delay, or selectively publish. -- Methodology changes (this document) ship as PRs and are independent of sponsor contracts. -- Relay.link is not currently a sponsor and has no editorial influence on this bench. - -## 14. Change log - -| Version | Date | Change | -|---|---|---| -| v1.0 | 2026-05-23 | Initial pre-registration. Single-protocol scope (Relay only), 24h / 7d / 30d windows, CoinGecko USD reference, 60 s poll cadence, upper-bound margin definition with explicit solver-share disclosure. | diff --git a/docs/walkthrough.md b/docs/walkthrough.md index d054f58b..b69e42fe 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/ChainBench/OpenChainBench/issues/new?template=new-benchmark.yml) template. They fill in: +Alex opens an issue with the [📊 Propose a benchmark](https://github.com/OpenChainBench/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/ChainBench/OpenChainBench/tree/main/harnesses/wallet-portfolio-latency +source: https://github.com/OpenChainBench/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/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. | +| 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. | ## What this enables diff --git a/harnesses/README.md b/harnesses/README.md index 04ff9c13..b6a1bff5 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/ChainBench/OpenChainBench/issues/new?template=new-benchmark.yml). +1. Open an issue with the [📊 Propose a benchmark template](https://github.com/OpenChainBench/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 8ef33851..ec3fb764 100644 --- a/harnesses/aggregator-head-lag/.gitignore +++ b/harnesses/aggregator-head-lag/.gitignore @@ -32,6 +32,3 @@ 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 a0cce9a2..0f7455e0 100644 --- a/harnesses/aggregator-head-lag/cmd/script/log_buffer.go +++ b/harnesses/aggregator-head-lag/cmd/script/log_buffer.go @@ -79,16 +79,12 @@ func installLogCapture() { } // setupLogsEndpoint exposes GET /logs?tail=N (default 500, max logBufferMax). -// 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. +// 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. 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 { + if expectedToken != "" && 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 84444379..c0a7eaa7 100644 --- a/harnesses/aggregator-head-lag/cmd/script/main.go +++ b/harnesses/aggregator-head-lag/cmd/script/main.go @@ -47,17 +47,50 @@ func main() { } }() - // 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. + // 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) wg.Add(1) go func() { defer wg.Done() runHeadLagMonitor(config, stopChan) }() - // Mobula Fast-Trade monitor — used for the detailed Mobula breakdown - // (processing vs network lag) emitted alongside head lag. + // Mobula Fast-Trade monitor (for comparison with Pulse V2) wg.Add(1) go func() { defer wg.Done() diff --git a/harnesses/hyperliquid-frontends/Dockerfile b/harnesses/hyperliquid-frontends/Dockerfile deleted file mode 100644 index 89acec73..00000000 --- a/harnesses/hyperliquid-frontends/Dockerfile +++ /dev/null @@ -1,13 +0,0 @@ -FROM golang:1.23 AS build -WORKDIR /src -COPY go.mod go.sum* ./ -RUN go mod download || true -COPY . . -RUN CGO_ENABLED=0 go build -o /out/script ./cmd/script - -FROM gcr.io/distroless/static-debian12:nonroot -WORKDIR /app -COPY --from=build /out/script /app/script -COPY builders.json /app/builders.json -EXPOSE 2112 -ENTRYPOINT ["/app/script"] diff --git a/harnesses/hyperliquid-frontends/README.md b/harnesses/hyperliquid-frontends/README.md deleted file mode 100644 index 11e994e7..00000000 --- a/harnesses/hyperliquid-frontends/README.md +++ /dev/null @@ -1,78 +0,0 @@ -# hyperliquid-frontends harness - -OpenChainBench bench № 030 — quality benchmark of Hyperliquid frontends, ranked by **how much they extract from users**, not by raw volume share. - -## What it measures - -Three quality metrics per builder, refreshed hourly from the public daily fills dumps Hyperliquid publishes at `https://stats-data.hyperliquid.xyz/Mainnet/builder_fills/{address}/{YYYYMMDD}.csv.lz4`: - -| Metric | Definition | -|---|---| -| **Effective fee bps** | `sum(builder_fee) / sum(notional) × 10 000` — volume-weighted average over 24 h | -| **$ per user** | `sum(builder_fee) / count(distinct user)` — raw efficiency per active trader | -| **Fee discipline** | `stddev_over_time(effective_fee_bps[30d])` — computed in Prometheus, surfaces rotating-promo cycles | - -Headline ranking = lowest effective fee = most aligned with traders. - -Why this framing: the Hyperliquid frontend wars already have a dozen volume-share dashboards (ASXN HyperScreener, Coinmarketman HyperTracker, Flowscan, Allium, Hyperdash, several Dune boards). What nobody publishes cleanly is the user-cost side. This bench is the user-cost side. - -## Builder registry - -`builders.json` is a hand-curated `[{slug, name, address, valid_from, notes}]` array. Addresses are cross-referenced against: -- Flowscan `/builders` — live leaderboard -- Hyperliquid governance forum disclosures -- Each frontend's public announcement of their builder code - -**The committed file ships with placeholder `0x0000…` addresses.** They MUST be filled in before the harness can fetch anything useful. The harness will return `403` for every placeholder address (the Hyperliquid bucket returns 403 when the date file doesn't exist for that address). - -Workflow for adding / updating addresses: -1. Pull the latest Flowscan builders list, cross-check with ASXN's published mapping -2. Edit `builders.json`, bump `valid_from` to today -3. PR with a Flowscan screenshot in the description so the registry change is auditable -4. After merge, redeploy the harness on Railway - -## Prometheus metrics emitted - -``` -hl_frontend_effective_fee_bps{builder} gauge -hl_frontend_fees_per_user_usd{builder} gauge -hl_frontend_volume_usd_24h{builder} gauge -hl_frontend_users_24h{builder} gauge -hl_frontend_fills_total{builder} gauge -hl_frontend_unattributed_share_pct gauge -hl_frontend_registry_age_seconds gauge -hl_frontend_csv_fetch_status_total{builder,code} counter -``` - -Bench page column mapping is documented in `benchmarks/hyperliquid-frontends.yml` — p50 / p90 / p99 are repurposed for effective fee / 30d stddev / $/user respectively since the unit is bps and there is no native percentile semantics. - -## Run locally - -```bash -cd harnesses/hyperliquid-frontends -go run ./cmd/script -curl http://localhost:2112/metrics | grep hl_frontend_ -``` - -## Deploy - -Standard OCB-miniapp shape — multi-stage Dockerfile, port 2112, scraped by the shared `openchainbench-monitoring` Prometheus via `hyperliquid-frontends.railway.internal:2112`. - -After deploy, add a scrape config entry: - -```yaml -- job_name: 'hyperliquid-frontends' - static_configs: - - targets: - - 'hyperliquid-frontends.railway.internal:2112' - labels: - benchmark: hyperliquid-frontends - metrics_path: /metrics -``` - -## Known limits - -- **Daily granularity** — the CSV bucket only rolls at UTC midnight. Intra-day movement is invisible. A v1.1 upgrade would consume the WebSocket `userFills` stream filtered on the `b` field for realtime. -- **Native HL UI excluded** — orders without a builder code are not attributable here. Covered by the separate `/benchmarks/aggregator-head-lag` bench. -- **Wash trading** — fills are taken at face value. A frontend running wash flow shows up exactly as the chain records. Mitigation = the unique-user column flags anomalous low-user / high-volume signatures. A sybil-cluster heuristic ships in v1.1. -- **Registry maintenance** — the builder address list is hand-curated. The `hl_frontend_unattributed_share_pct` metric makes the coverage gap visible; an alert fires when it crosses 2 %. diff --git a/harnesses/hyperliquid-frontends/builders.json b/harnesses/hyperliquid-frontends/builders.json deleted file mode 100644 index c6f8f0ec..00000000 --- a/harnesses/hyperliquid-frontends/builders.json +++ /dev/null @@ -1,58 +0,0 @@ -[ - { - "slug": "phantom-perps", - "name": "Phantom", - "address": "0xb84168cf3be63c6b8dad05ff5d755e97432ff80b", - "valid_from": "2026-05-31", - "notes": "Phantom wallet's Hyperliquid perps integration. Verified via CoinMarketMan HyperTracker registry + 200 OK on the public fills bucket on 2026-05-29 and 2026-05-30." - }, - { - "slug": "axiom", - "name": "Axiom", - "address": "0x1cc34f6af34653c515b47a83e1de70ba9b0cda1f", - "valid_from": "2026-05-31", - "notes": "Axiom trading terminal (Solana-native, expanding to HL). Verified via CMM registry + bucket 200 OK." - }, - { - "slug": "pvp-trade", - "name": "pvp.trade", - "address": "0x0cbf655b0d22ae71fba3a674b0e1c0c7e7f975af", - "valid_from": "2026-05-31", - "notes": "Social PvP trading rooms. Verified via CMM registry + bucket 200 OK + CMM per-builder URL." - }, - { - "slug": "insilico", - "name": "Insilico", - "address": "0x2868fc0d9786a740b491577a43502259efa78a39", - "valid_from": "2026-05-31", - "notes": "Insilico Terminal (institutional trading workstation). Verified via CMM registry + bucket 200 OK + CMM per-builder URL." - }, - { - "slug": "defiapp", - "name": "Defiapp", - "address": "0x1922810825c90f4270048b96da7b1803cd8609ef", - "valid_from": "2026-05-31", - "notes": "Defiapp HL frontend. Verified via bucket 200 OK with real data (sample test: 133 fills, $164K notional, 5 bps eff, 2026-05-29)." - }, - { - "slug": "metamask", - "name": "MetaMask", - "address": "0xe95a5e31904e005066614247d309e00d8ad753aa", - "valid_from": "2026-05-31", - "notes": "MetaMask HL integration (primary address). MetaMask is documented to operate a secondary address 0xea2c82b5aba243ab631c0ce151763d5e38df75b3 — aggregate manually if needed." - }, - { - "slug": "dexari", - "name": "Dexari", - "address": "0x7975cafdff839ed5047244ed3a0dd82a89866081", - "valid_from": "2026-05-31", - "notes": "Dexari HL frontend (previously called Dexterity in our internal docs — naming corrected after Flowscan cross-reference). Verified via CMM registry." - }, - { - "slug": "okto", - "name": "Okto", - "address": "0x05984fd37db96dc2a11a09519a8def556e80590b", - "valid_from": "2026-05-31", - "notes": "Okto HL integration (primary address). Documented secondary: 0x4fe1141b9066f3777f4bd4d4ac9d216173031dc1." - } -] diff --git a/harnesses/hyperliquid-frontends/cmd/script/fetcher.go b/harnesses/hyperliquid-frontends/cmd/script/fetcher.go deleted file mode 100644 index f04cc952..00000000 --- a/harnesses/hyperliquid-frontends/cmd/script/fetcher.go +++ /dev/null @@ -1,225 +0,0 @@ -package main - -import ( - "context" - "encoding/csv" - "fmt" - "io" - "net/http" - "strconv" - "strings" - "time" - - "github.com/pierrec/lz4/v4" -) - -// processBuilder fetches today's + yesterday's Hyperliquid fills CSV -// for one builder address, aggregates the last complete UTC day, -// updates every Prom metric for that builder, AND upserts each fill -// into the SQLite state so the retention pass later in the cycle -// sees fresh data. Returns the day notional so the caller can -// compute the cross-builder volume share. -// -// The headline window is `yesterday's complete UTC day` (i.e. the -// CSV for date T-1). We switched from rolling-24h because the -// rolling cutoff was unfairly zeroing builders whose flow lands -// early in the UTC day — at 18:00 UTC the cutoff `now - 24h` is -// 18:00 yesterday, so a builder that traded between 00:00-17:00 -// yesterday would show 0. The UTC-day window gives every builder -// the same comparison surface at the cost of 0-24h of staleness, -// which is fine for a fee-quality bench (frontends don't change -// pricing minute-to-minute). Today's CSV is still fetched so the -// SQLite state captures the in-progress day for retention math. -func processBuilder(ctx context.Context, b Builder, state *State) float64 { - now := time.Now().UTC() - - // Hyperliquid publishes the per-day CSV with a variable delay — - // usually T-1 lands within hours of UTC midnight, but the bucket - // has gone 48 h+ without a fresh file (observed 2026-05-31). To - // avoid zeroing every builder on a publish lag, walk back up to - // 3 days and pick the most recent date that returned a non-empty - // CSV. State always upserts from whatever days we did fetch so - // retention math gets the freshest signal regardless. - dates := []time.Time{ - now.AddDate(0, 0, -1), - now.AddDate(0, 0, -2), - now.AddDate(0, 0, -3), - } - var headlineFills []fillRow - var headlineDateKey string - for _, d := range dates { - batch, code, err := fetchDay(ctx, b.Address, d) - hlCSVFetchStatus.WithLabelValues(b.Slug, code).Inc() - if err != nil { - fmt.Printf("[%s] %s: %s err=%v\n", b.Slug, d.Format("20060102"), code, err) - continue - } - // State always gets every fill — retention math wants the - // long history of unique users, not just the headline day. - for _, f := range batch { - if state != nil && f.User != "" { - if err := state.Upsert(b.Slug, f.User, f.Time.UnixMilli(), f.Px*f.Sz); err != nil { - fmt.Printf("[%s] state upsert error: %v\n", b.Slug, err) - } - } - } - // Pick the first day that has at least one fill — that's - // the freshest "complete UTC day" for this builder. - if headlineDateKey == "" && len(batch) > 0 { - headlineFills = batch - headlineDateKey = d.Format("20060102") - } - } - - var ( - notionalUSD float64 - builderFeeUSD float64 - fillCount int - users = make(map[string]struct{}) - ) - for _, f := range headlineFills { - notionalUSD += f.Px * f.Sz - builderFeeUSD += f.BuilderFee - fillCount++ - if f.User != "" { - users[f.User] = struct{}{} - } - } - - hlFillsTotal.WithLabelValues(b.Slug).Set(float64(fillCount)) - hlVolumeUSD24h.WithLabelValues(b.Slug).Set(notionalUSD) - hlUsers24h.WithLabelValues(b.Slug).Set(float64(len(users))) - - if notionalUSD > 0 { - hlEffectiveFeeBps.WithLabelValues(b.Slug).Set(builderFeeUSD / notionalUSD * 10_000) - } - if len(users) > 0 { - hlFeesPerUserUSD.WithLabelValues(b.Slug).Set(builderFeeUSD / float64(len(users))) - } - - fmt.Printf("[%s] day=%s fills=%d notional=$%.0f fees=$%.2f users=%d eff=%.2fbps\n", - b.Slug, headlineDateKey, fillCount, notionalUSD, builderFeeUSD, len(users), - safeRatio(builderFeeUSD, notionalUSD)*10_000) - return notionalUSD -} - -type fillRow struct { - Time time.Time - User string - Coin string - Side string - Px float64 - Sz float64 - BuilderFee float64 -} - -// fetchDay GETs the per-day CSV dump, LZ4-decompresses, parses into -// fillRow slices. Returns (rows, http_code_label, err). The -// http_code_label is the string we want to label the -// `hl_frontend_csv_fetch_status_total` counter with — "200" for OK, -// "403" for "no fills that day" (Hyperliquid bucket returns 403 when -// the date file doesn't exist), "5xx" for server errors, "error" for -// transport-level failures. -func fetchDay(ctx context.Context, address string, day time.Time) ([]fillRow, string, error) { - url := fmt.Sprintf( - "https://stats-data.hyperliquid.xyz/Mainnet/builder_fills/%s/%s.csv.lz4", - strings.ToLower(address), - day.Format("20060102"), - ) - reqCtx, cancel := context.WithTimeout(ctx, httpTimeout) - defer cancel() - req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, url, nil) - if err != nil { - return nil, "error", err - } - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, "error", err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - if resp.StatusCode >= 500 { - return nil, "5xx", fmt.Errorf("http %d", resp.StatusCode) - } - return nil, fmt.Sprintf("%d", resp.StatusCode), nil - } - - dec := lz4.NewReader(resp.Body) - rd := csv.NewReader(dec) - rd.FieldsPerRecord = -1 - header, err := rd.Read() - if err != nil { - return nil, "200", fmt.Errorf("header: %w", err) - } - col := map[string]int{} - for i, h := range header { - col[strings.TrimSpace(h)] = i - } - required := []string{"time", "user", "px", "sz", "builder_fee"} - for _, r := range required { - if _, ok := col[r]; !ok { - return nil, "200", fmt.Errorf("missing column %q in header %v", r, header) - } - } - - var rows []fillRow - for { - rec, err := rd.Read() - if err == io.EOF { - break - } - if err != nil { - return rows, "200", fmt.Errorf("row: %w", err) - } - if len(rec) < len(header) { - continue - } - t, err := parseFillTime(rec[col["time"]]) - if err != nil { - continue - } - px, _ := strconv.ParseFloat(rec[col["px"]], 64) - sz, _ := strconv.ParseFloat(rec[col["sz"]], 64) - fee, _ := strconv.ParseFloat(rec[col["builder_fee"]], 64) - rows = append(rows, fillRow{ - Time: t, - User: strings.ToLower(rec[col["user"]]), - Coin: safeCol(rec, col, "coin"), - Side: safeCol(rec, col, "side"), - Px: px, - Sz: sz, - BuilderFee: fee, - }) - } - return rows, "200", nil -} - -func safeCol(rec []string, col map[string]int, k string) string { - i, ok := col[k] - if !ok || i >= len(rec) { - return "" - } - return rec[i] -} - -// parseFillTime accepts the two timestamp shapes Hyperliquid has -// shipped over the lifetime of the bucket: Unix-millis integers and -// RFC3339 strings. Both have been observed in the wild. -func parseFillTime(raw string) (time.Time, error) { - raw = strings.TrimSpace(raw) - if raw == "" { - return time.Time{}, fmt.Errorf("empty time") - } - if ms, err := strconv.ParseInt(raw, 10, 64); err == nil { - return time.UnixMilli(ms).UTC(), nil - } - return time.Parse(time.RFC3339Nano, raw) -} - -func safeRatio(a, b float64) float64 { - if b == 0 { - return 0 - } - return a / b -} diff --git a/harnesses/hyperliquid-frontends/cmd/script/main.go b/harnesses/hyperliquid-frontends/cmd/script/main.go deleted file mode 100644 index 586ed70a..00000000 --- a/harnesses/hyperliquid-frontends/cmd/script/main.go +++ /dev/null @@ -1,146 +0,0 @@ -package main - -// Hyperliquid frontends quality benchmark. -// -// Pulls the per-builder, per-day fills CSV that the Hyperliquid team -// publishes at https://stats-data.hyperliquid.xyz/Mainnet/builder_fills/ -// then computes three quality metrics per builder: -// -// - effective_fee_bps = sum(builder_fee) / sum(notional) * 10000 -// - fees_per_user_usd = sum(builder_fee) / count(distinct user) -// - volume_usd_24h = sum(px * sz) -// -// 30-day fee discipline is NOT computed here — it falls out for free -// via `stddev_over_time(hl_frontend_effective_fee_bps[30d])` in -// Prometheus once the time-series accumulates. -// -// MVP shape — wire it up, point at the public bucket, expose Prom on -// :2112. Builder address registry lives at ../builders.json so a new -// builder is added without code change. - -import ( - "context" - "fmt" - "os" - "os/signal" - "syscall" - "time" -) - -const ( - scrapeInterval = 3600 * time.Second // poll each builder hourly - httpTimeout = 30 * time.Second -) - -func main() { - fmt.Println("=== Hyperliquid Frontends Quality Harness ===") - fmt.Println("OpenChainBench - builder-code effective fee + $/user + volume.") - fmt.Println() - - registry, err := loadRegistry("./builders.json") - if err != nil { - fmt.Printf("[fatal] load registry: %v\n", err) - os.Exit(1) - } - fmt.Printf("Registry: %d builders\n", len(registry)) - for _, b := range registry { - fmt.Printf(" · %-15s %s\n", b.Slug, b.Address) - } - fmt.Println() - - state, err := OpenState() - if err != nil { - fmt.Printf("[fatal] open state: %v\n", err) - os.Exit(1) - } - defer state.Close() - - fmt.Println("Metrics server: :2112/metrics") - fmt.Println() - - // Hardcoded :2112 per the OCB Railway scrape convention. We - // deliberately ignore Railway's $PORT (would move the listener - // away from where the shared Prom expects it). METRICS_ADDR is - // a local-dev escape hatch only — never set in production. - addr := os.Getenv("METRICS_ADDR") - if addr == "" { - addr = ":2112" - } - go func() { - if err := StartMetricsServer(addr); err != nil { - fmt.Printf("[fatal] metrics server: %v\n", err) - os.Exit(1) - } - }() - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - // First cycle fires after a 5 s warmup so /metrics has data on the - // first scrape; subsequent cycles run on the configured interval. - warmup := time.NewTimer(5 * time.Second) - defer warmup.Stop() - ticker := time.NewTicker(scrapeInterval) - defer ticker.Stop() - - sig := make(chan os.Signal, 1) - signal.Notify(sig, os.Interrupt, syscall.SIGTERM) - - for { - select { - case <-warmup.C: - runCycle(ctx, registry, state) - case <-ticker.C: - runCycle(ctx, registry, state) - case s := <-sig: - fmt.Printf("\n[shutdown] received %v\n", s) - cancel() - return - } - } -} - -func runCycle(ctx context.Context, registry []Builder, state *State) { - cycleStart := time.Now() - // Per-builder pass: fetch CSVs, update gauges, upsert into state. - var totalNotional float64 - perBuilderNotional := make(map[string]float64, len(registry)) - for _, b := range registry { - select { - case <-ctx.Done(): - return - default: - } - notional := processBuilder(ctx, b, state) - perBuilderNotional[b.Slug] = notional - totalNotional += notional - } - // Volume share + retention pass once state is up to date. - for _, b := range registry { - if totalNotional > 0 { - hlVolumeSharePct.WithLabelValues(b.Slug).Set(perBuilderNotional[b.Slug] / totalNotional * 100) - } - for _, days := range []int{7, 30} { - ret, cohort, err := state.Retention(ctx, b.Slug, days) - if err != nil { - fmt.Printf("[%s] retention(%dd) error: %v\n", b.Slug, days, err) - continue - } - switch days { - case 7: - hlD7Retention.WithLabelValues(b.Slug).Set(ret) - hlD7CohortSize.WithLabelValues(b.Slug).Set(float64(cohort)) - case 30: - hlD30Retention.WithLabelValues(b.Slug).Set(ret) - hlD30CohortSize.WithLabelValues(b.Slug).Set(float64(cohort)) - } - } - } - // Prune anything past our 90-day horizon so the SQLite file stays - // bounded. 90d > our longest retention window (D30) with 2x safety - // margin for backfill / replay scenarios. - if n, err := state.PruneOlderThan(90); err == nil && n > 0 { - fmt.Printf("[state] pruned %d rows older than 90d\n", n) - } - fmt.Printf("[cycle] done in %s\n", time.Since(cycleStart).Round(time.Millisecond)) -} diff --git a/harnesses/hyperliquid-frontends/cmd/script/metrics.go b/harnesses/hyperliquid-frontends/cmd/script/metrics.go deleted file mode 100644 index 5d9e074a..00000000 --- a/harnesses/hyperliquid-frontends/cmd/script/metrics.go +++ /dev/null @@ -1,136 +0,0 @@ -package main - -import ( - "net/http" - - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/promauto" - "github.com/prometheus/client_golang/prometheus/promhttp" -) - -// Prom metrics for the Hyperliquid frontends quality bench. Names are -// prefixed `hl_frontend_` so the OCB MCP allowlist needs one entry -// (`hl_frontend_`) for every metric this harness emits. - -var ( - hlEffectiveFeeBps = promauto.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "hl_frontend_effective_fee_bps", - Help: "Volume-weighted effective builder fee in basis points over the last 24h: sum(builder_fee) / sum(notional) * 10000.", - }, - []string{"builder"}, - ) - - hlFeesPerUserUSD = promauto.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "hl_frontend_fees_per_user_usd", - Help: "USD fees captured per unique trader over the last 24h: sum(builder_fee_usd) / count(distinct user).", - }, - []string{"builder"}, - ) - - hlVolumeUSD24h = promauto.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "hl_frontend_volume_usd_24h", - Help: "Total notional USD volume routed via this builder in the last 24h. Secondary signal — not the headline.", - }, - []string{"builder"}, - ) - - hlUsers24h = promauto.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "hl_frontend_users_24h", - Help: "Count of unique trader addresses attributed to this builder in the last 24h.", - }, - []string{"builder"}, - ) - - hlFillsTotal = promauto.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "hl_frontend_fills_total", - Help: "Count of attributed fills observed in the last 24h. Drives the leaderboard sample_size column.", - }, - []string{"builder"}, - ) - - hlUnattributedShare = promauto.NewGauge( - prometheus.GaugeOpts{ - Name: "hl_frontend_unattributed_share_pct", - Help: "Share of Hyperliquid builder-fee volume from addresses NOT in our registry. >2% should trigger a registry-update review.", - }, - ) - - hlRegistryAge = promauto.NewGauge( - prometheus.GaugeOpts{ - Name: "hl_frontend_registry_age_seconds", - Help: "Seconds since the builders.json file was last modified on disk. Surfaces stale-registry drift.", - }, - ) - - hlCSVFetchStatus = promauto.NewCounterVec( - prometheus.CounterOpts{ - Name: "hl_frontend_csv_fetch_status_total", - Help: "Outcome of each fetch of the per-day Hyperliquid fills CSV, by HTTP code (200, 403=no fills that day, 5xx, error).", - }, - []string{"builder", "code"}, - ) - - // Cohort retention — the unique OCB edge. Of the users whose first - // observed fill for a builder lands in the 24h window {7,30} days - // ago, the fraction that traded again in the last 24h. Requires - // the SQLite state layer because no public source publishes it. - hlD7Retention = promauto.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "hl_frontend_d7_retention_pct", - Help: "D7 cohort retention in percent: of users who first traded via this builder 7 days ago, fraction that traded again in the last 24h.", - }, - []string{"builder"}, - ) - - hlD30Retention = promauto.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "hl_frontend_d30_retention_pct", - Help: "D30 cohort retention in percent: of users who first traded via this builder 30 days ago, fraction that traded again in the last 24h.", - }, - []string{"builder"}, - ) - - hlD7CohortSize = promauto.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "hl_frontend_d7_cohort_size", - Help: "Number of users in the D7 cohort. Low cohort sizes (<50) make the retention percentage statistically noisy.", - }, - []string{"builder"}, - ) - - hlD30CohortSize = promauto.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "hl_frontend_d30_cohort_size", - Help: "Number of users in the D30 cohort.", - }, - []string{"builder"}, - ) - - // Volume share computed across builders in the registry. Every - // other dashboard has this — we expose it as secondary so the - // page still surfaces the number readers expect to see. - hlVolumeSharePct = promauto.NewGaugeVec( - prometheus.GaugeOpts{ - Name: "hl_frontend_volume_share_pct", - Help: "Share in percent of the last 24h notional volume across builders in our registry. Coverage gap (= volume from unregistered addresses) surfaces in hl_frontend_unattributed_share_pct.", - }, - []string{"builder"}, - ) -) - -func StartMetricsServer(addr string) error { - mux := http.NewServeMux() - mux.Handle("/metrics", promhttp.Handler()) - mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte("ok")) - }) - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte("hyperliquid-frontends harness · OpenChainBench")) - }) - return http.ListenAndServe(addr, mux) -} diff --git a/harnesses/hyperliquid-frontends/cmd/script/registry.go b/harnesses/hyperliquid-frontends/cmd/script/registry.go deleted file mode 100644 index af26d153..00000000 --- a/harnesses/hyperliquid-frontends/cmd/script/registry.go +++ /dev/null @@ -1,40 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "os" - "strings" -) - -// Builder maps a curated frontend slug to its Hyperliquid builder-code -// EVM address. The address is what the on-chain `ApproveBuilderFee` -// action registers and what every order with that builder embedded -// surfaces in the per-day CSV dumps. -type Builder struct { - Slug string `json:"slug"` - Name string `json:"name"` - Address string `json:"address"` - ValidFrom string `json:"valid_from"` - Notes string `json:"notes,omitempty"` -} - -func loadRegistry(path string) ([]Builder, error) { - raw, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("read %s: %w", path, err) - } - var entries []Builder - if err := json.Unmarshal(raw, &entries); err != nil { - return nil, fmt.Errorf("parse %s: %w", path, err) - } - for i := range entries { - // Lowercase addresses — the Hyperliquid stats bucket is case - // sensitive (paths only resolve when the address is lower). - entries[i].Address = strings.ToLower(entries[i].Address) - } - if len(entries) == 0 { - return nil, fmt.Errorf("empty registry: %s", path) - } - return entries, nil -} diff --git a/harnesses/hyperliquid-frontends/cmd/script/state.go b/harnesses/hyperliquid-frontends/cmd/script/state.go deleted file mode 100644 index c967dfa5..00000000 --- a/harnesses/hyperliquid-frontends/cmd/script/state.go +++ /dev/null @@ -1,135 +0,0 @@ -package main - -import ( - "context" - "database/sql" - "fmt" - "os" - "path/filepath" - "time" - - _ "modernc.org/sqlite" -) - -// State layer = pure-Go SQLite under $STATE_DIR/state.db (defaults to -// ./state.db). One table records (builder, user) first-seen / last-seen -// timestamps so we can compute cohort retention without re-parsing the -// full CSV history every cycle. -// -// This is the differentiator vs every other Hyperliquid frontends -// dashboard — nobody publishes D7/D30 retention per builder cleanly -// because nobody bothers maintaining the state. Five lines of SQL -// plus a single 100 MB file on a Railway volume gets us a metric the -// rest of the field can't match. - -type State struct { - db *sql.DB -} - -func OpenState() (*State, error) { - dir := os.Getenv("STATE_DIR") - if dir == "" { - dir = "." - } - if err := os.MkdirAll(dir, 0o755); err != nil { - return nil, fmt.Errorf("mkdir state: %w", err) - } - path := filepath.Join(dir, "state.db") - db, err := sql.Open("sqlite", path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)") - if err != nil { - return nil, fmt.Errorf("open sqlite %s: %w", path, err) - } - if _, err := db.Exec(` - CREATE TABLE IF NOT EXISTS user_activity ( - builder TEXT NOT NULL, - user TEXT NOT NULL, - first_seen_ms INTEGER NOT NULL, - last_seen_ms INTEGER NOT NULL, - fills INTEGER NOT NULL DEFAULT 0, - notional_usd REAL NOT NULL DEFAULT 0, - PRIMARY KEY (builder, user) - ); - CREATE INDEX IF NOT EXISTS idx_user_activity_first_seen - ON user_activity(builder, first_seen_ms); - CREATE INDEX IF NOT EXISTS idx_user_activity_last_seen - ON user_activity(builder, last_seen_ms); - `); err != nil { - return nil, fmt.Errorf("schema: %w", err) - } - fmt.Printf("[state] sqlite opened at %s\n", path) - return &State{db: db}, nil -} - -// Upsert one observation. Idempotent: re-running the same CSV is safe -// because first_seen is `MIN(existing, candidate)` and last_seen is -// `MAX(existing, candidate)`. Fills + notional aggregate via SUM but -// we de-dupe on the caller side per cycle (only NEW rows passed in). -func (s *State) Upsert(builder, user string, tsMs int64, notionalUSD float64) error { - _, err := s.db.Exec(` - INSERT INTO user_activity (builder, user, first_seen_ms, last_seen_ms, fills, notional_usd) - VALUES (?, ?, ?, ?, 1, ?) - ON CONFLICT(builder, user) DO UPDATE SET - first_seen_ms = MIN(first_seen_ms, excluded.first_seen_ms), - last_seen_ms = MAX(last_seen_ms, excluded.last_seen_ms), - fills = fills + 1, - notional_usd = notional_usd + excluded.notional_usd - `, builder, user, tsMs, tsMs, notionalUSD) - return err -} - -// Retention computes cohort retention for the given builder + window -// in days. Definition: of users whose first_seen_ms falls in the -// 24-hour window starting `days` ago, what fraction had a last_seen_ms -// within the last 24 hours. -// -// cohort = COUNT first_seen ∈ [now - days*24h, now - (days-1)*24h) -// active = COUNT first_seen ∈ that bucket AND last_seen ≥ (now - 24h) -// ratio = active / cohort (0 when cohort empty) -// -// D7 → days=7. D30 → days=30. -func (s *State) Retention(ctx context.Context, builder string, days int) (float64, int, error) { - now := time.Now().UTC().UnixMilli() - cohortStart := now - int64(days)*86_400_000 - cohortEnd := now - int64(days-1)*86_400_000 - activeAfter := now - 86_400_000 - - var cohort, returned int - // COALESCE because SUM(CASE) returns NULL on an empty result - // set (no users in the cohort window). database/sql can't scan - // NULL into a plain int, so we coerce to 0 in SQL. - err := s.db.QueryRowContext(ctx, ` - SELECT - COUNT(*) AS cohort, - COALESCE(SUM(CASE WHEN last_seen_ms >= ? THEN 1 ELSE 0 END), 0) AS returned - FROM user_activity - WHERE builder = ? - AND first_seen_ms >= ? - AND first_seen_ms < ? - `, activeAfter, builder, cohortStart, cohortEnd).Scan(&cohort, &returned) - if err != nil { - return 0, 0, err - } - if cohort == 0 { - return 0, 0, nil - } - return float64(returned) / float64(cohort) * 100, cohort, nil -} - -// PruneOlderThan removes activity rows older than the retention horizon -// so the SQLite file doesn't grow forever. Run once per cycle. -func (s *State) PruneOlderThan(days int) (int64, error) { - cutoff := time.Now().UTC().Add(-time.Duration(days) * 24 * time.Hour).UnixMilli() - res, err := s.db.Exec(`DELETE FROM user_activity WHERE last_seen_ms < ?`, cutoff) - if err != nil { - return 0, err - } - n, _ := res.RowsAffected() - return n, nil -} - -func (s *State) Close() error { - if s == nil || s.db == nil { - return nil - } - return s.db.Close() -} diff --git a/harnesses/hyperliquid-frontends/go.mod b/harnesses/hyperliquid-frontends/go.mod deleted file mode 100644 index 2fe449b4..00000000 --- a/harnesses/hyperliquid-frontends/go.mod +++ /dev/null @@ -1,9 +0,0 @@ -module github.com/OpenChainBench/OpenChainBench/harnesses/hyperliquid-frontends - -go 1.23 - -require ( - github.com/pierrec/lz4/v4 v4.1.21 - github.com/prometheus/client_golang v1.20.5 - modernc.org/sqlite v1.34.1 -) diff --git a/harnesses/hyperliquid-frontends/go.sum b/harnesses/hyperliquid-frontends/go.sum deleted file mode 100644 index 55d3db80..00000000 --- a/harnesses/hyperliquid-frontends/go.sum +++ /dev/null @@ -1,73 +0,0 @@ -github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= -github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= -github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= -github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= -github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= -github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= -github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= -github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= -github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= -github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= -github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= -github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= -github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= -github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= -github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= -golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= -golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= -golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= -google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= -google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= -modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= -modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= -modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= -modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= -modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= -modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= -modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= -modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= -modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= -modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= -modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= -modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= -modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= -modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= -modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= -modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= -modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= -modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= -modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= -modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= -modernc.org/sqlite v1.34.1 h1:u3Yi6M0N8t9yKRDwhXcyp1eS5/ErhPTBggxWFuR6Hfk= -modernc.org/sqlite v1.34.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k= -modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= -modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= -modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= -modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/harnesses/l1-finality/cmd/script/config.go b/harnesses/l1-finality/cmd/script/config.go index 91270f66..0bb19fd6 100644 --- a/harnesses/l1-finality/cmd/script/config.go +++ b/harnesses/l1-finality/cmd/script/config.go @@ -33,11 +33,6 @@ 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 { @@ -98,11 +93,7 @@ func loadConfig() *Config { Slug: "litecoin", Name: "Litecoin", Kind: KindBitcoinLike, - // 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"), + RPCURL: getenvDefault("RPC_LITECOIN", "https://api.blockchair.com/litecoin"), // Coinbase deposit standard for LTC. The April 2026 13-block // MWEB reorg made 6 unsafe; 12 is the post-incident floor. Confirmations: 12, @@ -123,13 +114,6 @@ 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 09696a9d..7566676f 100644 --- a/harnesses/l1-finality/cmd/script/main.go +++ b/harnesses/l1-finality/cmd/script/main.go @@ -51,36 +51,6 @@ 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() @@ -98,12 +68,8 @@ 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 d7974a79..8b563b08 100644 --- a/harnesses/l1-finality/cmd/script/pow.go +++ b/harnesses/l1-finality/cmd/script/pow.go @@ -5,24 +5,28 @@ import ( "fmt" "io" "net/http" + "strconv" "time" ) -// 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 +// 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). -type esploraBlock struct { - ID string `json:"id"` // block hash - Height int64 `json:"height"` - Timestamp int64 `json:"timestamp"` // unix seconds, UTC +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"` } func fetchBitcoinLike(ch ChainConfig) FinalitySample { @@ -30,71 +34,71 @@ func fetchBitcoinLike(ch ChainConfig) FinalitySample { start := time.Now() client := &http.Client{Timeout: 10 * time.Second} - blocks, err := esploraRecentBlocks(client, ch.RPCURL) - s.FetchLatencyMs = time.Since(start).Milliseconds() + stats, err := blockchairStatsFetch(client, ch.RPCURL) if err != nil { - s.Err = fmt.Sprintf("blocks: %v", err) + s.Err = fmt.Sprintf("stats: %v", err) + s.FetchLatencyMs = time.Since(start).Milliseconds() return s } - if len(blocks) == 0 { - s.Err = "blocks: empty" + 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() return s } + s.FetchLatencyMs = time.Since(start).Milliseconds() - 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 - } + 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) return s } - final := blocks[finalIdx] - s.LatestBlock = tip.Height - s.FinalizedBlock = final.Height + s.LatestBlock = latestHeight + s.FinalizedBlock = finalHeight s.BlockLag = s.LatestBlock - s.FinalizedBlock - s.LagSeconds = float64(tip.Timestamp - final.Timestamp) + s.LagSeconds = latestTs.Sub(finalTs).Seconds() if s.LagSeconds < 0 { s.LagSeconds = 0 } return s } -// 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") +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 } -// 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)) +type blockchairSimpleBlock struct { + ID int64 `json:"id"` + Time string `json:"time"` } -func esploraGetBlocks(client *http.Client, url string) ([]esploraBlock, error) { - resp, err := client.Get(url) +func blockchairBlockAt(client *http.Client, base string, height int64) (*blockchairSimpleBlock, error) { + resp, err := client.Get(base + "/dashboards/block/" + strconv.FormatInt(height, 10)) if err != nil { return nil, err } @@ -103,9 +107,12 @@ func esploraGetBlocks(client *http.Client, url string) ([]esploraBlock, error) { if resp.StatusCode != 200 { return nil, fmt.Errorf("status_%d: %s", resp.StatusCode, truncate(string(body), 200)) } - var blocks []esploraBlock - if err := json.Unmarshal(body, &blocks); err != nil { + var parsed blockchairBlock + if err := json.Unmarshal(body, &parsed); err != nil { return nil, fmt.Errorf("parse: %v", err) } - return blocks, nil + 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)) } diff --git a/harnesses/l2-block-time/cmd/script/sequencer_ws.go b/harnesses/l2-block-time/cmd/script/sequencer_ws.go index 26d59f69..e46f39e0 100644 --- a/harnesses/l2-block-time/cmd/script/sequencer_ws.go +++ b/harnesses/l2-block-time/cmd/script/sequencer_ws.go @@ -5,7 +5,6 @@ import ( "fmt" "strconv" "strings" - "sync/atomic" "time" "github.com/gorilla/websocket" @@ -31,14 +30,6 @@ 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 { @@ -112,20 +103,13 @@ func runChain(ch L2Chain) error { blockTimeHealth.WithLabelValues(ch.Slug).Set(1) fmt.Printf("[%s] connected, subscribed to newHeads\n", ch.Slug) - 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()) - + pingDone := make(chan struct{}) go func() { t := time.NewTicker(pingEvery) defer t.Stop() for { select { - case <-done: + case <-pingDone: return case <-t.C: _ = conn.WriteControl( @@ -136,30 +120,7 @@ func runChain(ch L2Chain) error { } } }() - // 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) + defer close(pingDone) conn.SetPongHandler(func(string) error { _ = conn.SetReadDeadline(time.Now().Add(readDeadline)) @@ -198,7 +159,6 @@ 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 1ac3850b..403feca4 100644 --- a/harnesses/metadata-coverage/README.md +++ b/harnesses/metadata-coverage/README.md @@ -4,6 +4,8 @@ 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 c7169802..2c74ae71 100644 --- a/harnesses/metadata-coverage/alertmanager/Dockerfile +++ b/harnesses/metadata-coverage/alertmanager/Dockerfile @@ -1,15 +1,12 @@ FROM prom/alertmanager:latest -# Config carries __SLACK_WEBHOOK_URL__; entrypoint.sh sed-substitutes -# it from $SLACK_WEBHOOK_URL at container start. Keeps the real webhook -# URL out of the repo and out of the image layer. +# Copy AlertManager configuration COPY alertmanager.yml /etc/alertmanager/alertmanager.yml -COPY entrypoint.sh /entrypoint.sh - -USER root -RUN chmod +x /entrypoint.sh -USER nobody +# Expose AlertManager port EXPOSE 9093 -ENTRYPOINT ["/entrypoint.sh"] +# Run AlertManager +CMD ["--config.file=/etc/alertmanager/alertmanager.yml", \ + "--storage.path=/alertmanager", \ + "--log.level=debug"] diff --git a/harnesses/metadata-coverage/alertmanager/alertmanager.yml b/harnesses/metadata-coverage/alertmanager/alertmanager.yml index a498dc05..6ea7ebaa 100644 --- a/harnesses/metadata-coverage/alertmanager/alertmanager.yml +++ b/harnesses/metadata-coverage/alertmanager/alertmanager.yml @@ -9,12 +9,9 @@ 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: '__SLACK_WEBHOOK_URL__' + - url: 'https://agent-slack-production.up.railway.app/webhook/grafana' send_resolved: true inhibit_rules: diff --git a/harnesses/metadata-coverage/alertmanager/entrypoint.sh b/harnesses/metadata-coverage/alertmanager/entrypoint.sh deleted file mode 100755 index 425532d9..00000000 --- a/harnesses/metadata-coverage/alertmanager/entrypoint.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/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 b47bc3ec..f011653a 100644 --- a/harnesses/metadata-coverage/docker-compose.yml +++ b/harnesses/metadata-coverage/docker-compose.yml @@ -36,14 +36,15 @@ services: - monitor alertmanager: - build: - context: ./alertmanager - dockerfile: Dockerfile + image: prom/alertmanager:latest container_name: alertmanager ports: - "9093:9093" - environment: - - SLACK_WEBHOOK_URL=${SLACK_WEBHOOK_URL} + volumes: + - ./monitoring/alertmanager.yml:/etc/alertmanager/alertmanager.yml + command: + - '--config.file=/etc/alertmanager/alertmanager.yml' + - '--storage.path=/alertmanager' networks: - monitoring restart: unless-stopped diff --git a/harnesses/metadata-coverage/monitoring/alertmanager.yml b/harnesses/metadata-coverage/monitoring/alertmanager.yml index 6c0f533c..77b9e263 100644 --- a/harnesses/metadata-coverage/monitoring/alertmanager.yml +++ b/harnesses/metadata-coverage/monitoring/alertmanager.yml @@ -9,12 +9,9 @@ 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: '__SLACK_WEBHOOK_URL__' + - url: 'https://agent-slack-production.up.railway.app/webhook/grafana' send_resolved: true inhibit_rules: diff --git a/harnesses/metadata-coverage/prometheus/alertmanager.yml b/harnesses/metadata-coverage/prometheus/alertmanager.yml index 6c0f533c..77b9e263 100644 --- a/harnesses/metadata-coverage/prometheus/alertmanager.yml +++ b/harnesses/metadata-coverage/prometheus/alertmanager.yml @@ -9,12 +9,9 @@ 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: '__SLACK_WEBHOOK_URL__' + - url: 'https://agent-slack-production.up.railway.app/webhook/grafana' send_resolved: true inhibit_rules: diff --git a/harnesses/network-coverage/cmd/script/coinpaprika.go b/harnesses/network-coverage/cmd/script/coinpaprika.go deleted file mode 100644 index 46bd5696..00000000 --- a/harnesses/network-coverage/cmd/script/coinpaprika.go +++ /dev/null @@ -1,56 +0,0 @@ -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 deleted file mode 100644 index 344c6ee7..00000000 --- a/harnesses/network-coverage/cmd/script/coinstats.go +++ /dev/null @@ -1,62 +0,0 @@ -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 311ace2b..0b356452 100644 --- a/harnesses/network-coverage/cmd/script/config.go +++ b/harnesses/network-coverage/cmd/script/config.go @@ -12,9 +12,6 @@ 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 @@ -26,9 +23,6 @@ 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, @@ -49,8 +43,7 @@ func loadConfig() *Config { } else if c.CodexSessionCookie != "" { codexAuth = "cookie+mint" } - 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 != "") + fmt.Printf("Config: refresh=%v, testnets=%v, mobula_key=%v, codex=%s\n", + c.RefreshInterval, c.IncludeTestnets, c.MobulaAPIKey != "", codexAuth) return c } diff --git a/harnesses/network-coverage/cmd/script/covalent.go b/harnesses/network-coverage/cmd/script/covalent.go deleted file mode 100644 index 740d22b2..00000000 --- a/harnesses/network-coverage/cmd/script/covalent.go +++ /dev/null @@ -1,83 +0,0 @@ -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 bcaab8b0..04c7f24f 100644 --- a/harnesses/network-coverage/cmd/script/main.go +++ b/harnesses/network-coverage/cmd/script/main.go @@ -71,10 +71,6 @@ 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 deleted file mode 100644 index 508abf0d..00000000 --- a/harnesses/network-coverage/cmd/script/simdune.go +++ /dev/null @@ -1,76 +0,0 @@ -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 3d1ec9aa..38a4327c 100644 --- a/harnesses/rpc-capabilities/README.md +++ b/harnesses/rpc-capabilities/README.md @@ -109,43 +109,11 @@ 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`. - -### 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). - -| 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. +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`. ## Known limits + +- **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`). - **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 e4069813..826df89d 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, currentRegion, fmt.Sprintf("%d", d)).Set(0) + rpcArchiveDepth.WithLabelValues(p.Slug, c.Slug, 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, currentRegion, fmt.Sprintf("%d", d)).Set(val) + rpcArchiveDepth.WithLabelValues(p.Slug, c.Slug, 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 25088458..c47c3836 100644 --- a/harnesses/rpc-capabilities/cmd/script/main.go +++ b/harnesses/rpc-capabilities/cmd/script/main.go @@ -5,7 +5,6 @@ import ( "fmt" "os" "os/signal" - "strings" "syscall" ) @@ -16,53 +15,9 @@ 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 899714fe..8ec2470e 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", "region"}, + []string{"provider", "chain"}, ) 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", "region"}, + []string{"provider", "chain"}, ) 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", "region", "result"}, + []string{"provider", "chain", "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", "region"}, + []string{"provider", "chain"}, ) 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", "region", "depth"}, + []string{"provider", "chain", "depth"}, ) ) diff --git a/harnesses/rpc-capabilities/cmd/script/probe.go b/harnesses/rpc-capabilities/cmd/script/probe.go index b6324445..72115353 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, currentRegion).Set(latency) - rpcLatencyHist.WithLabelValues(p.Slug, c.Slug, currentRegion).Observe(latency) + rpcLatency.WithLabelValues(p.Slug, c.Slug).Set(latency) + rpcLatencyHist.WithLabelValues(p.Slug, c.Slug).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, currentRegion, result).Inc() + rpcCallTotal.WithLabelValues(p.Slug, c.Slug, result).Inc() if result == "ok" { - rpcHealth.WithLabelValues(p.Slug, c.Slug, currentRegion).Set(1) + rpcHealth.WithLabelValues(p.Slug, c.Slug).Set(1) fmt.Printf("[%s/%s] block=%d latency=%.0fms\n", c.Slug, p.Slug, block, latency) } else { - rpcHealth.WithLabelValues(p.Slug, c.Slug, currentRegion).Set(0) + rpcHealth.WithLabelValues(p.Slug, c.Slug).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 deleted file mode 100644 index e0efb9c9..00000000 --- a/harnesses/solana-quote-latency/.env.example +++ /dev/null @@ -1,19 +0,0 @@ -# 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 deleted file mode 100644 index 089d940c..00000000 --- a/harnesses/solana-quote-latency/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/monitor diff --git a/harnesses/solana-quote-latency/Dockerfile b/harnesses/solana-quote-latency/Dockerfile deleted file mode 100644 index 8868166b..00000000 --- a/harnesses/solana-quote-latency/Dockerfile +++ /dev/null @@ -1,28 +0,0 @@ -# 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 deleted file mode 100644 index f278b564..00000000 --- a/harnesses/solana-quote-latency/README.md +++ /dev/null @@ -1,104 +0,0 @@ -# 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 deleted file mode 100644 index 848bbe8b..00000000 --- a/harnesses/solana-quote-latency/cmd/monitor/config.go +++ /dev/null @@ -1,82 +0,0 @@ -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 deleted file mode 100644 index 03f2d4b2..00000000 --- a/harnesses/solana-quote-latency/cmd/monitor/dflow.go +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index d3f3349f..00000000 --- a/harnesses/solana-quote-latency/cmd/monitor/http_client.go +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index bd10ec55..00000000 --- a/harnesses/solana-quote-latency/cmd/monitor/jupiter.go +++ /dev/null @@ -1,154 +0,0 @@ -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 deleted file mode 100644 index f4a66bb8..00000000 --- a/harnesses/solana-quote-latency/cmd/monitor/log_buffer.go +++ /dev/null @@ -1,113 +0,0 @@ -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 deleted file mode 100644 index b4d742ac..00000000 --- a/harnesses/solana-quote-latency/cmd/monitor/main.go +++ /dev/null @@ -1,59 +0,0 @@ -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 deleted file mode 100644 index 23b0602b..00000000 --- a/harnesses/solana-quote-latency/cmd/monitor/metrics.go +++ /dev/null @@ -1,115 +0,0 @@ -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 deleted file mode 100644 index 70812d20..00000000 --- a/harnesses/solana-quote-latency/cmd/monitor/mobula.go +++ /dev/null @@ -1,121 +0,0 @@ -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 deleted file mode 100644 index 703b44d5..00000000 --- a/harnesses/solana-quote-latency/cmd/monitor/openocean.go +++ /dev/null @@ -1,149 +0,0 @@ -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 deleted file mode 100644 index a544dd5c..00000000 --- a/harnesses/solana-quote-latency/cmd/monitor/provider.go +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index 67c59c38..00000000 --- a/harnesses/solana-quote-latency/cmd/monitor/pulse_subscriber.go +++ /dev/null @@ -1,244 +0,0 @@ -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 deleted file mode 100644 index 616d0d99..00000000 --- a/harnesses/solana-quote-latency/cmd/monitor/raydium.go +++ /dev/null @@ -1,120 +0,0 @@ -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 deleted file mode 100644 index e02287bf..00000000 --- a/harnesses/solana-quote-latency/cmd/monitor/scheduler.go +++ /dev/null @@ -1,135 +0,0 @@ -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 deleted file mode 100644 index 935ec23e..00000000 --- a/harnesses/solana-quote-latency/cmd/monitor/trending.go +++ /dev/null @@ -1,264 +0,0 @@ -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 deleted file mode 100644 index c777452a..00000000 --- a/harnesses/solana-quote-latency/go.mod +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index 02807ba4..00000000 --- a/harnesses/solana-quote-latency/go.sum +++ /dev/null @@ -1,48 +0,0 @@ -github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= -github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= -github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= -github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= -github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= -github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= -github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= -github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= -github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= -github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= -github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= -github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= -go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= -go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= -google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/harnesses/solana-tx-landing/cmd/script/senders.go b/harnesses/solana-tx-landing/cmd/script/senders.go index e0d88ffd..b1b74070 100644 --- a/harnesses/solana-tx-landing/cmd/script/senders.go +++ b/harnesses/solana-tx-landing/cmd/script/senders.go @@ -87,10 +87,6 @@ func buildProbes(enabled map[Service]bool) ([]serviceProbe, error) { buildNozomiProbe(tipWallets[ServiceNozomi]), buildAstralaneProbe(tipWallets[ServiceAstralane]), buildZeroSlotProbe(tipWallets[Service0slot]), - // Mobula has no own tip wallet (relays via Jito/Nozomi/zeroslot internally). - // Embed a Jito tip transfer so Mobula's default routing sees a valid tip; - // attribution uses signatureSubscribe (active probe path), not tip-wallet. - buildMobulaProbe(tipWallets[ServiceJito]), } out := make([]serviceProbe, 0, len(candidates)) @@ -277,10 +273,7 @@ func buildAstralaneProbe(tip solana.PublicKey) serviceProbe { } host := envDefault("ASTRALANE_ENDPOINT", "https://ny.gateway.astralane.io/iris") url := host + "?api-key=" + key - // Astralane raised their min tip floor 500_000 → 1_000_000 lamports - // (observed 2026-05-26 via prober alerts). Default to the new floor; - // the ASTRALANE_TIP_LAMPORTS env var still overrides. - tipLamports := uint64(envInt("ASTRALANE_TIP_LAMPORTS", 1_000_000)) + tipLamports := uint64(envInt("ASTRALANE_TIP_LAMPORTS", 500_000)) return serviceProbe{ Service: ServiceAstralane, @@ -443,109 +436,3 @@ func truncate(s string, n int) string { } return s[:n] + "…" } - -// ----------------------------------------------------------------------------- -// Mobula swap-send — multi-RPC fan-out aggregator -// docs.mobula.io/rest-api-reference/endpoint/swap-send -// -// Wire shape (verified 2026-05-27): -// - POST https://api.mobula.io/api/2/swap/send -// - Authorization: Bearer -// - Body: {"chainId": "solana:solana", "signedTransaction": ""} -// - Response on success: {"data": {"transactionHash": "", "requestId": "...", -// "success": true, "lander": "jito|nozomi|..."}} -// - 201 returned for accepted submission; downstream failure surfaces inside -// the JSON body via `error` / `data.success=false`. -// -// Mobula relays internally to Jito / Nozomi / zeroslot via multi-RPC fan-out, -// so there is no dedicated Mobula tip wallet. The probe embeds a Jito tip -// transfer (same wallet as the Jito control probe) so Mobula's default -// routing sees a valid tip on the underlying lander. Active-probe attribution -// works via signatureSubscribe on the tx signature, independent of the tip -// wallet used. -// ----------------------------------------------------------------------------- - -func buildMobulaProbe(tip solana.PublicKey) serviceProbe { - key := strings.TrimSpace(os.Getenv("MOBULA_API_KEY")) - if key == "" { - fmt.Println("[prober] mobula: MOBULA_API_KEY not set — service skipped") - return serviceProbe{} - } - url := envDefault("MOBULA_ENDPOINT", "https://api.mobula.io/api/2/swap/send") - tipLamports := uint64(envInt("MOBULA_TIP_LAMPORTS", 10_000)) - - return serviceProbe{ - Service: ServiceMobula, - Mode: "default", - Endpoint: url, - TipWallet: tip, - TipLamports: tipLamports, - Sender: func(ctx context.Context, tx *solana.Transaction) (string, error) { - return postMobulaSwapSend(ctx, url, key, tx) - }, - } -} - -func postMobulaSwapSend(ctx context.Context, url, apiKey string, tx *solana.Transaction) (string, error) { - txB64, err := txToBase64(tx) - if err != nil { - return "", err - } - reqBody := map[string]any{ - "chainId": "solana:solana", - "signedTransaction": txB64, - } - buf, err := json.Marshal(reqBody) - if err != nil { - return "", fmt.Errorf("marshal req: %w", err) - } - cctx, cancel := context.WithTimeout(ctx, senderHTTPTimeout) - defer cancel() - req, err := http.NewRequestWithContext(cctx, http.MethodPost, url, bytes.NewReader(buf)) - if err != nil { - return "", fmt.Errorf("new req: %w", err) - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+apiKey) - - resp, err := senderHTTPClient.Do(req) - if err != nil { - return "", fmt.Errorf("http: %s", scrubSecrets(err.Error())) - } - defer resp.Body.Close() - respBody, _ := io.ReadAll(resp.Body) - - if resp.StatusCode == 419 || resp.StatusCode == 429 { - return "", fmt.Errorf("http %d (rate limit): %s", - resp.StatusCode, scrubSecrets(truncate(string(respBody), 200))) - } - if resp.StatusCode >= 500 { - return "", fmt.Errorf("http %d: %s", - resp.StatusCode, scrubSecrets(truncate(string(respBody), 200))) - } - - var parsed struct { - Data struct { - TransactionHash string `json:"transactionHash"` - Success bool `json:"success"` - } `json:"data"` - Error string `json:"error"` - } - if err := json.Unmarshal(respBody, &parsed); err != nil { - return "", fmt.Errorf("decode resp (status=%d body=%s): %w", - resp.StatusCode, scrubSecrets(truncate(string(respBody), 200)), err) - } - if parsed.Error != "" { - return "", fmt.Errorf("mobula: %s", scrubSecrets(parsed.Error)) - } - if parsed.Data.TransactionHash != "" { - return parsed.Data.TransactionHash, nil - } - // Mobula sometimes returns 201 without echoing the sig. Fall back to - // the locally-computed signature (known once tx.Sign ran). - if len(tx.Signatures) == 0 { - return "", fmt.Errorf("no signature in response or on tx (status=%d body=%s)", - resp.StatusCode, scrubSecrets(truncate(string(respBody), 200))) - } - return tx.Signatures[0].String(), nil -} diff --git a/harnesses/solana-tx-landing/cmd/script/wallets.go b/harnesses/solana-tx-landing/cmd/script/wallets.go index 94e1d995..55db22e8 100644 --- a/harnesses/solana-tx-landing/cmd/script/wallets.go +++ b/harnesses/solana-tx-landing/cmd/script/wallets.go @@ -28,10 +28,6 @@ const ( ServiceNextBlock Service = "nextblock" ServiceAstralane Service = "astralane" ServiceSolanaVibeStation Service = "solanavibestation" - // Mobula swap-send. No dedicated tip wallets — Mobula relays internally - // to Jito/Nozomi/zeroslot via multi-RPC fan-out. Not observable via - // tip-wallet attribution (bench № 016); active-probe-only (bench № 027). - ServiceMobula Service = "mobula" ) // walletToService maps every known tip address to its owning service. diff --git a/package.json b/package.json index a548de37..ff2dbcba 100644 --- a/package.json +++ b/package.json @@ -16,14 +16,12 @@ }, "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": { @@ -33,7 +31,6 @@ "@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 72ee3b83..6496de2a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,9 +14,6 @@ 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 @@ -35,9 +32,6 @@ 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 @@ -60,9 +54,6 @@ 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) @@ -140,10 +131,6 @@ 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'} @@ -156,9 +143,6 @@ 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==} @@ -644,31 +628,6 @@ 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: @@ -796,9 +755,6 @@ 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==} @@ -825,23 +781,9 @@ 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} @@ -1101,9 +1043,6 @@ 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'} @@ -1129,9 +1068,6 @@ 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==} @@ -1515,9 +1451,6 @@ 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'} @@ -1667,9 +1600,6 @@ 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'} @@ -1817,11 +1747,6 @@ 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 @@ -2005,9 +1930,6 @@ 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'} @@ -2216,15 +2138,6 @@ 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'} @@ -2412,11 +2325,6 @@ 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==} @@ -2424,9 +2332,6 @@ 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'} @@ -2515,11 +2420,6 @@ 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'} @@ -2576,24 +2476,6 @@ 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': {} @@ -2675,8 +2557,6 @@ snapshots: dependencies: '@babel/types': 7.29.0 - '@babel/runtime@7.29.7': {} - '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 @@ -2700,8 +2580,6 @@ 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 @@ -3051,26 +2929,6 @@ 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 @@ -3172,8 +3030,6 @@ 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 @@ -3199,27 +3055,10 @@ 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 @@ -3490,8 +3329,6 @@ snapshots: balanced-match@4.0.4: {} - base64-js@1.5.1: {} - baseline-browser-mapping@2.10.23: {} body-parser@2.2.2: @@ -3529,11 +3366,6 @@ 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 @@ -4087,8 +3919,6 @@ snapshots: optionalDependencies: picomatch: 4.0.4 - fflate@0.8.3: {} - file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -4241,8 +4071,6 @@ snapshots: dependencies: safer-buffer: 2.1.2 - ieee754@1.2.1: {} - ignore@5.3.2: {} ignore@7.0.5: {} @@ -4393,13 +4221,6 @@ 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: {} @@ -4538,8 +4359,6 @@ snapshots: merge2@1.4.1: {} - meshoptimizer@1.1.1: {} - micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -4744,12 +4563,6 @@ 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: @@ -5032,16 +4845,10 @@ 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) @@ -5174,10 +4981,6 @@ 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: @@ -5244,9 +5047,3 @@ 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 5494a3e3..90e7f453 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 deleted file mode 100644 index cb141c3a..00000000 Binary files a/public/logos/apexliquid.png and /dev/null differ diff --git a/public/logos/arena.png b/public/logos/arena.png deleted file mode 100644 index 86f113ed..00000000 Binary files a/public/logos/arena.png and /dev/null differ diff --git a/public/logos/axiom.png b/public/logos/axiom.png deleted file mode 100644 index f0b82975..00000000 Binary files a/public/logos/axiom.png and /dev/null differ diff --git a/public/logos/based-app.png b/public/logos/based-app.png deleted file mode 100644 index 2a561786..00000000 Binary files a/public/logos/based-app.png and /dev/null differ diff --git a/public/logos/blocknative.svg b/public/logos/blocknative.svg index 815dc90b..75bc8d64 100644 --- a/public/logos/blocknative.svg +++ b/public/logos/blocknative.svg @@ -1,38 +1 @@ - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/public/logos/bullpen.svg b/public/logos/bullpen.svg deleted file mode 100644 index 31151bb6..00000000 --- a/public/logos/bullpen.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/public/logos/coin98.png b/public/logos/coin98.png deleted file mode 100644 index c1b73f55..00000000 Binary files a/public/logos/coin98.png and /dev/null differ diff --git a/public/logos/coinpaprika.svg b/public/logos/coinpaprika.svg deleted file mode 100644 index 122b7608..00000000 --- a/public/logos/coinpaprika.svg +++ /dev/null @@ -1 +0,0 @@ -LOGO_NMAsset 1 diff --git a/public/logos/coinpilot.png b/public/logos/coinpilot.png deleted file mode 100644 index 0458d808..00000000 Binary files a/public/logos/coinpilot.png and /dev/null differ diff --git a/public/logos/coinstats.svg b/public/logos/coinstats.svg deleted file mode 100644 index dd88ae09..00000000 --- a/public/logos/coinstats.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/logos/covalent.svg b/public/logos/covalent.svg deleted file mode 100644 index 76d2cfce..00000000 --- a/public/logos/covalent.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/public/logos/defiapp.svg b/public/logos/defiapp.svg deleted file mode 100644 index 98be168f..00000000 --- a/public/logos/defiapp.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/public/logos/dexari.png b/public/logos/dexari.png deleted file mode 100644 index 3de07938..00000000 Binary files a/public/logos/dexari.png and /dev/null differ diff --git a/public/logos/dexly-trade.svg b/public/logos/dexly-trade.svg deleted file mode 100644 index 2459ff6e..00000000 --- a/public/logos/dexly-trade.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/public/logos/dreamcash.png b/public/logos/dreamcash.png deleted file mode 100644 index c291da34..00000000 Binary files a/public/logos/dreamcash.png and /dev/null differ diff --git a/public/logos/dune.png b/public/logos/dune.png deleted file mode 100644 index 2e51e70e..00000000 Binary files a/public/logos/dune.png and /dev/null differ diff --git a/public/logos/dune.svg b/public/logos/dune.svg new file mode 100644 index 00000000..a33d329e --- /dev/null +++ b/public/logos/dune.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/public/logos/echosync.png b/public/logos/echosync.png deleted file mode 100644 index 6786ce97..00000000 Binary files a/public/logos/echosync.png and /dev/null differ diff --git a/public/logos/flowbot.jpg b/public/logos/flowbot.jpg deleted file mode 100644 index e3e7510c..00000000 Binary files a/public/logos/flowbot.jpg and /dev/null differ diff --git a/public/logos/fomo.png b/public/logos/fomo.png deleted file mode 100644 index 75e342f1..00000000 Binary files a/public/logos/fomo.png and /dev/null differ diff --git a/public/logos/gemwallet.svg b/public/logos/gemwallet.svg deleted file mode 100644 index 8f9c1b5b..00000000 --- a/public/logos/gemwallet.svg +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/public/logos/grider.jpg b/public/logos/grider.jpg deleted file mode 100644 index 8c34b28c..00000000 Binary files a/public/logos/grider.jpg and /dev/null differ diff --git a/public/logos/gtr-trade.png b/public/logos/gtr-trade.png deleted file mode 100644 index 25118385..00000000 Binary files a/public/logos/gtr-trade.png and /dev/null differ diff --git a/public/logos/hyperdash.jpg b/public/logos/hyperdash.jpg deleted file mode 100644 index 0a09ee6a..00000000 Binary files a/public/logos/hyperdash.jpg and /dev/null differ diff --git a/public/logos/hyprearn.png b/public/logos/hyprearn.png deleted file mode 100644 index 7a185931..00000000 Binary files a/public/logos/hyprearn.png and /dev/null differ diff --git a/public/logos/infinex.jpg b/public/logos/infinex.jpg deleted file mode 100644 index 0212a691..00000000 Binary files a/public/logos/infinex.jpg and /dev/null differ diff --git a/public/logos/insilico.svg b/public/logos/insilico.svg deleted file mode 100644 index f1019d51..00000000 --- a/public/logos/insilico.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/logos/katoshi.svg b/public/logos/katoshi.svg deleted file mode 100644 index 51080e82..00000000 --- a/public/logos/katoshi.svg +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/public/logos/lava.webp b/public/logos/lava.webp index 8b4c0076..4c90a195 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 deleted file mode 100644 index 8aa18106..00000000 Binary files a/public/logos/legend-trade.png and /dev/null differ diff --git a/public/logos/liminal.jpg b/public/logos/liminal.jpg deleted file mode 100644 index 894c7778..00000000 Binary files a/public/logos/liminal.jpg and /dev/null differ diff --git a/public/logos/liquid-perps.jpg b/public/logos/liquid-perps.jpg deleted file mode 100644 index 5ad297bd..00000000 Binary files a/public/logos/liquid-perps.jpg and /dev/null differ diff --git a/public/logos/lit-trade.png b/public/logos/lit-trade.png deleted file mode 100644 index cf60cc4e..00000000 Binary files a/public/logos/lit-trade.png and /dev/null differ diff --git a/public/logos/lootbase.png b/public/logos/lootbase.png deleted file mode 100644 index 41257e3b..00000000 Binary files a/public/logos/lootbase.png and /dev/null differ diff --git a/public/logos/mass-dot-money.svg b/public/logos/mass-dot-money.svg deleted file mode 100644 index 84f41524..00000000 --- a/public/logos/mass-dot-money.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/public/logos/metamask.svg b/public/logos/metamask.svg deleted file mode 100644 index 47ccb58a..00000000 --- a/public/logos/metamask.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/public/logos/metascalp.svg b/public/logos/metascalp.svg deleted file mode 100644 index 6f7ad8a1..00000000 --- a/public/logos/metascalp.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/logos/minaraai.png b/public/logos/minaraai.png deleted file mode 100644 index 5c2d6c53..00000000 Binary files a/public/logos/minaraai.png and /dev/null differ diff --git a/public/logos/moonbot.png b/public/logos/moonbot.png deleted file mode 100644 index fa1280ab..00000000 Binary files a/public/logos/moonbot.png and /dev/null differ diff --git a/public/logos/moontrader.svg b/public/logos/moontrader.svg deleted file mode 100644 index 9e15fdaa..00000000 --- a/public/logos/moontrader.svg +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/public/logos/nautilus-trader.png b/public/logos/nautilus-trader.png deleted file mode 100644 index 8bf97ab9..00000000 Binary files a/public/logos/nautilus-trader.png and /dev/null differ diff --git a/public/logos/okto.png b/public/logos/okto.png deleted file mode 100644 index c7159696..00000000 Binary files a/public/logos/okto.png and /dev/null differ diff --git a/public/logos/onekey.png b/public/logos/onekey.png deleted file mode 100644 index 4b0e1833..00000000 Binary files a/public/logos/onekey.png and /dev/null differ diff --git a/public/logos/openocean.png b/public/logos/openocean.png deleted file mode 100644 index ee985eb5..00000000 Binary files a/public/logos/openocean.png and /dev/null differ diff --git a/public/logos/pear.svg b/public/logos/pear.svg deleted file mode 100644 index 8ef27a6d..00000000 --- a/public/logos/pear.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/logos/perpmate.png b/public/logos/perpmate.png deleted file mode 100644 index 37e19753..00000000 Binary files a/public/logos/perpmate.png and /dev/null differ diff --git a/public/logos/phantom-perps.svg b/public/logos/phantom-perps.svg deleted file mode 100644 index c17833ef..00000000 --- a/public/logos/phantom-perps.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/public/logos/pvp-trade.png b/public/logos/pvp-trade.png deleted file mode 100644 index 62af58dd..00000000 Binary files a/public/logos/pvp-trade.png and /dev/null differ diff --git a/public/logos/rabby.png b/public/logos/rabby.png deleted file mode 100644 index 5304e332..00000000 Binary files a/public/logos/rabby.png and /dev/null differ diff --git a/public/logos/rainbow.png b/public/logos/rainbow.png deleted file mode 100644 index 77303683..00000000 Binary files a/public/logos/rainbow.png and /dev/null differ diff --git a/public/logos/ranger-finance.svg b/public/logos/ranger-finance.svg deleted file mode 100644 index dee508e0..00000000 --- a/public/logos/ranger-finance.svg +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/public/logos/raydium.svg b/public/logos/raydium.svg deleted file mode 100644 index d014e8aa..00000000 --- a/public/logos/raydium.svg +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/public/logos/senpi.png b/public/logos/senpi.png deleted file mode 100644 index 73139847..00000000 Binary files a/public/logos/senpi.png and /dev/null differ diff --git a/public/logos/silhouette.jpg b/public/logos/silhouette.jpg deleted file mode 100644 index 2a330726..00000000 Binary files a/public/logos/silhouette.jpg and /dev/null differ diff --git a/public/logos/supercexy.svg b/public/logos/supercexy.svg deleted file mode 100644 index e233e20b..00000000 --- a/public/logos/supercexy.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/public/logos/superstack.jpg b/public/logos/superstack.jpg deleted file mode 100644 index 6c7a15e2..00000000 Binary files a/public/logos/superstack.jpg and /dev/null differ diff --git a/public/logos/superx.avif b/public/logos/superx.avif deleted file mode 100644 index 13091e19..00000000 Binary files a/public/logos/superx.avif and /dev/null differ diff --git a/public/logos/supurr.svg b/public/logos/supurr.svg deleted file mode 100644 index 6d3be61d..00000000 --- a/public/logos/supurr.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/public/logos/sushi.png b/public/logos/sushi.png deleted file mode 100644 index 8c9f1d93..00000000 Binary files a/public/logos/sushi.png and /dev/null differ diff --git a/public/logos/taco-trade.jpg b/public/logos/taco-trade.jpg deleted file mode 100644 index 273dffe3..00000000 Binary files a/public/logos/taco-trade.jpg and /dev/null differ diff --git a/public/logos/tenderly.svg b/public/logos/tenderly.svg index b3c05826..c0bf3dd8 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 1488a209..14ae5e17 100644 --- a/src/app/alternatives/[slug]/page.tsx +++ b/src/app/alternatives/[slug]/page.tsx @@ -76,24 +76,6 @@ 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": [ @@ -101,7 +83,7 @@ export default async function AlternativePage({ "@type": "Dataset", "@id": `${url}#dataset`, name: `${alt.target_product} alternatives, benchmark snapshot`, - description: altDescriptionPadded, + description: alt.description ?? alt.intro.slice(0, 280), url, identifier: alt.slug, keywords: [ @@ -122,7 +104,7 @@ export default async function AlternativePage({ "@type": "Article", "@id": `${url}#article`, headline: `${alt.target_product} alternatives`, - description: altDescriptionPadded, + description: alt.description ?? alt.intro.slice(0, 280), url, mainEntityOfPage: url, articleBody: alt.intro, diff --git a/src/app/api/badge/[slug]/[provider]/route.ts b/src/app/api/badge/[slug]/[provider]/route.ts index d54fbfa7..70fa99bb 100644 --- a/src/app/api/badge/[slug]/[provider]/route.ts +++ b/src/app/api/badge/[slug]/[provider]/route.ts @@ -1,33 +1,18 @@ /** * Embeddable SVG badge. One badge per (benchmark, provider). * - * Endpoint shape: /api/badge//?chain= + * Endpoint shape: /api/badge// * * 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; @@ -57,51 +42,6 @@ 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)"; @@ -113,11 +53,6 @@ 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 }, @@ -139,46 +74,8 @@ export async function GET( headers: { "cache-control": "public, s-maxage=60" }, }); } - - // 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" - : ""; - } + const r = rankOf(b.results, provider, b.higherIsBetter); + if (!r) return new NextResponse("not found", { status: 404 }); // Colour signals rank. green for #1, dark ink for everyone else. const accent = r.rank === 1 ? "#3F7B47" : "#22272F"; @@ -191,39 +88,24 @@ 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}${scopeAriaSuffix}, ${value} ${suffix} - - - + + OpenChainBench. ${escapeXml(b.title)}. ${rankLabel}, ${value} ${suffix} + + + ${ocbBrand} ${rankLabel} ${escapeXml(title)} ${value} ${suffix} - ${scopeSvg} `; return new NextResponse(svg, { headers: { "Content-Type": "image/svg+xml; charset=utf-8", - // 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", + "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 7892bede..0c5d2c48 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, loadAllBenchmarks } from "@/lib/spec"; +import { getSpecs } from "@/lib/spec"; import { extractMetricName, Prometheus } from "@/lib/prometheus"; export const runtime = "nodejs"; @@ -214,28 +214,12 @@ 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 4348ea36..a56fbcd6 100644 --- a/src/app/api/mcp/[transport]/route.ts +++ b/src/app/api/mcp/[transport]/route.ts @@ -35,9 +35,7 @@ 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", @@ -46,40 +44,12 @@ 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_", - "wallet_labels_", - // Perp fees + buyback + oracle + validator yield "perp_fees_", - "ocb_buyback_", - "ocb_oracle_", - "ocb_validator_", - "ocb_chain_", - // Gas oracle prediction accuracy - "gas_error_", - "gas_predicted_", - "gas_realized_", - "gas_oracle_", - // Stablecoin peg (+ usdt-anchored variant) - "peg_", - // Solana TX landing (observational + active) - "solana_landing_", - // Public RPC capabilities - "rpc_latency_", - "rpc_call_total", - "rpc_health", - "rpc_archive_depth_supported", - // Bridge revenue (Relay-style implied margin) - "relay_", - "per_swap_margin_usd", - // Hyperliquid frontends quality bench (bench № 030) - "hl_frontend_", + "wallet_labels_", ]; // PromQL identifiers that are NOT metric names - built-in functions, @@ -320,22 +290,13 @@ const mcpHandler = createMcpHandler( " • a derived metric (rates, ratios, deltas)", " • a histogram bucket aggregation across chains/regions", "", - "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}`.", + "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}`.", "", "Example: instant p50 over 1h for Mobula head-lag on Base:", " query_prom({", @@ -364,7 +325,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 to 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–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 347c3042..77e95033 100644 --- a/src/app/apple-icon.tsx +++ b/src/app/apple-icon.tsx @@ -26,6 +26,7 @@ export default function AppleIcon() { justifyContent: "center", }} > + {/* eslint-disable-next-line @next/next/no-img-element */} OpenChainBench ({ slug })); } -// 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); + const b = 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( ( @@ -121,19 +72,18 @@ export default async function OG({ }} > {b.metric} - {chainLabel ? ` · ${chainLabel}` : ""}
38 ? 76 : 92, + fontSize: b.title.length > 38 ? 76 : 92, fontWeight: 700, lineHeight: 0.98, letterSpacing: -2, maxWidth: 1080, }} > - {titleText} + {b.title}
; - searchParams?: Promise>; }): Promise { const { slug } = await params; - // 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; + const b = await getBenchmark(slug); + if (!b) return {}; + const metaTitle = b.seoTitle ?? b.title; // 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. @@ -97,32 +65,14 @@ 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); - let description = + const description = b.seoDescription ?? (sentence ? `${sentence} ${b.subtitle}` : b.subtitle); - // 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; + const url = `${SITE.url}/benchmarks/${b.slug}`; return { title: metaTitle, description, - alternates: { canonical }, - openGraph: { - title: metaTitle, - description, - type: "article", - url: ogUrl, - }, + alternates: { canonical: url }, + openGraph: { title: metaTitle, description, type: "article", url }, twitter: { card: "summary_large_image", title: metaTitle, description }, }; } @@ -171,31 +121,7 @@ export default async function BenchmarkPage({ ), getBenchmarks(), ]); - // 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 variants: Record = Object.fromEntries(variantList); const benchmark = variants[variantKey(chain, region)] ?? aggregate; const isDraft = benchmark.status === "draft"; @@ -298,7 +224,7 @@ export default async function BenchmarkPage({ const faqJsonLd = buildFaqPageJsonLd(benchmark.faq, benchmarkUrl); return ( -
+