diff --git a/.github/workflows/hf-space-deploy.yml b/.github/workflows/hf-space-deploy.yml
new file mode 100644
index 00000000..7d9a2bfb
--- /dev/null
+++ b/.github/workflows/hf-space-deploy.yml
@@ -0,0 +1,82 @@
+name: HF Space deploy
+
+# Pushes scripts/hf_space/ to the HF Space at
+# https://huggingface.co/spaces/OpenChainBench/leaderboard.
+#
+# Trigger:
+# - push to main that touches scripts/hf_space/**
+# - workflow_dispatch for manual redeploys
+#
+# Required secrets:
+# HF_TOKEN write-scoped token for the Space repo (same token as the
+# dataset publisher).
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - "scripts/hf_space/**"
+ - ".github/workflows/hf-space-deploy.yml"
+ workflow_dispatch:
+
+concurrency:
+ group: hf-space-deploy
+ cancel-in-progress: false
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ permissions:
+ contents: read
+ env:
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
+ HF_SPACE_REPO_ID: OpenChainBench/leaderboard
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.11"
+
+ - name: Install huggingface_hub
+ run: pip install "huggingface_hub>=0.26"
+
+ - name: Upload to HF Space
+ env:
+ GIT_SHA: ${{ github.sha }}
+ run: |
+ python - <<'PY'
+ import os
+ from huggingface_hub import HfApi
+
+ token = os.environ["HF_TOKEN"]
+ repo_id = os.environ["HF_SPACE_REPO_ID"]
+ sha = os.environ.get("GIT_SHA", "manual")
+ api = HfApi(token=token)
+ api.create_repo(
+ repo_id=repo_id,
+ repo_type="space",
+ space_sdk="gradio",
+ exist_ok=True,
+ private=False,
+ )
+ info = api.upload_folder(
+ folder_path="scripts/hf_space",
+ repo_id=repo_id,
+ repo_type="space",
+ commit_message=f"deploy from {sha}",
+ )
+ print("uploaded:", getattr(info, "oid", "ok"))
+ PY
+
+ - name: Summary
+ if: always()
+ run: |
+ {
+ echo "## HF Space deploy ${{ job.status }}"
+ echo ""
+ echo "**Space:** https://huggingface.co/spaces/${HF_SPACE_REPO_ID}"
+ echo "**Commit:** ${{ github.sha }}"
+ echo "**Time:** $(date -u +%FT%TZ)"
+ } >> "$GITHUB_STEP_SUMMARY"
diff --git a/.gitignore b/.gitignore
index 5ca4a66c..b40a263f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -62,7 +62,8 @@ harnesses/*/monitor
harnesses/*/cmd/script/script
harnesses/*/cmd/monitor/monitor
-# Python venvs for the HF publisher (created locally for dry-runs)
+# Python venvs for the HF publisher and Space (created locally for dry-runs)
scripts/hf_publisher/.venv/
+scripts/hf_space/.venv/
**/__pycache__/
*.pyc
diff --git a/scripts/hf_space/README.md b/scripts/hf_space/README.md
new file mode 100644
index 00000000..c862d70a
--- /dev/null
+++ b/scripts/hf_space/README.md
@@ -0,0 +1,52 @@
+---
+title: OpenChainBench Leaderboard
+emoji: 📊
+colorFrom: indigo
+colorTo: blue
+sdk: gradio
+sdk_version: 4.44.1
+python_version: "3.11"
+app_file: app.py
+license: cc-by-4.0
+pinned: false
+short_description: Interactive leaderboard for OpenChainBench crypto infra benchmarks
+---
+
+# OpenChainBench leaderboard
+
+A small Gradio app that reads the daily parquet snapshot from the
+[OpenChainBench/benchmarks](https://huggingface.co/datasets/OpenChainBench/benchmarks)
+dataset and lets you browse it. Four tabs: today's leaderboard, per-chain
+leaders, per-provider rankings, and an about page.
+
+The dataset itself is the source of truth. This Space is a viewer on top
+of it. If you want raw access, query the parquet files directly with
+`polars`, `duckdb`, `pandas`, or any tool that speaks parquet.
+
+```python
+import polars as pl
+
+df = pl.scan_parquet(
+ "hf://datasets/OpenChainBench/benchmarks/headlines/**/*.parquet",
+ hive_partitioning=True,
+)
+print(df.collect().head())
+```
+
+For the full website with methodology, per-bench detail pages, and the
+historical view, head to [openchainbench.com](https://openchainbench.com).
+
+## Local dev
+
+```bash
+pip install -r requirements.txt
+python app.py
+```
+
+Open http://127.0.0.1:7860.
+
+## License
+
+Code in this Space is part of the OpenChainBench repo. The dataset is
+released under CC-BY-4.0. Attribution: link back to openchainbench.com or
+the dataset page.
diff --git a/scripts/hf_space/app.py b/scripts/hf_space/app.py
new file mode 100644
index 00000000..c60a4f59
--- /dev/null
+++ b/scripts/hf_space/app.py
@@ -0,0 +1,304 @@
+"""
+Gradio Space for the OpenChainBench public dataset.
+
+Loads parquet partitions directly from the HF dataset at
+hf://datasets/OpenChainBench/benchmarks via polars, surfaces a
+sortable / filterable leaderboard, per-chain leaders, and per-provider
+rankings. No local cache, no auth, no state. Each tab refresh re-reads
+the latest snapshot from HF, which is cheap because polars only scans
+the columns it needs.
+
+Run locally:
+ pip install -r requirements.txt
+ python app.py
+
+The HF Space picks up `app_file: app.py` from README.md frontmatter.
+"""
+
+from __future__ import annotations
+
+import functools
+import logging
+from typing import Any
+
+import gradio as gr
+import polars as pl
+
+logger = logging.getLogger("ocb_space")
+logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
+
+DATASET_REPO = "OpenChainBench/benchmarks"
+DATASET_URL = f"https://huggingface.co/datasets/{DATASET_REPO}"
+SITE_URL = "https://openchainbench.com"
+GITHUB_URL = "https://github.com/ChainBench/OpenChainBench"
+
+FOOTER = (
+ f"Data sourced from {DATASET_URL} (CC-BY-4.0). Updated daily."
+)
+
+# Hive partition layout:
/snapshot_date=YYYY-MM-DD/part-0.parquet.
+# Globbing the partitions and reading only the most recent snapshot_date
+# keeps the scan small even as the dataset accumulates history.
+HF_BASE = f"hf://datasets/{DATASET_REPO}"
+
+
+@functools.lru_cache(maxsize=1)
+def latest_snapshot_date() -> str:
+ """Pick the most recent snapshot_date present in headlines.
+
+ Scans the partition column only, no row data is materialized. Result
+ is cached for the lifetime of the process so every tab call reuses
+ the same date.
+ """
+ lf = pl.scan_parquet(f"{HF_BASE}/headlines/**/*.parquet", hive_partitioning=True)
+ dates = lf.select("snapshot_date").unique().collect()
+ latest = dates["snapshot_date"].max()
+ if latest is None:
+ raise RuntimeError("no snapshots found in headlines/")
+ logger.info("latest snapshot: %s", latest)
+ return str(latest)
+
+
+def _read_table(table: str, snapshot: str) -> pl.DataFrame:
+ lf = pl.scan_parquet(
+ f"{HF_BASE}/{table}/**/*.parquet", hive_partitioning=True
+ ).filter(pl.col("snapshot_date") == snapshot)
+ return lf.collect()
+
+
+@functools.lru_cache(maxsize=1)
+def headlines_df() -> pl.DataFrame:
+ return _read_table("headlines", latest_snapshot_date())
+
+
+@functools.lru_cache(maxsize=1)
+def providers_df() -> pl.DataFrame:
+ return _read_table("providers", latest_snapshot_date())
+
+
+@functools.lru_cache(maxsize=1)
+def chain_leaders_df() -> pl.DataFrame:
+ return _read_table("chain_leaders", latest_snapshot_date())
+
+
+def _categories() -> list[str]:
+ df = headlines_df()
+ if "category" not in df.columns:
+ return ["All"]
+ cats = sorted({c for c in df["category"].to_list() if c})
+ return ["All", *cats]
+
+
+def _bench_slugs() -> list[str]:
+ df = headlines_df()
+ return sorted({s for s in df["slug"].to_list() if s})
+
+
+def _bench_choices_for_chains() -> list[str]:
+ df = chain_leaders_df()
+ if df.is_empty():
+ return ["All"]
+ return ["All", *sorted({s for s in df["bench_slug"].to_list() if s})]
+
+
+def _chain_choices() -> list[str]:
+ df = chain_leaders_df()
+ if df.is_empty():
+ return ["All"]
+ return ["All", *sorted({s for s in df["chain"].to_list() if s})]
+
+
+def view_headlines(category: str) -> Any:
+ df = headlines_df()
+ if category and category != "All":
+ df = df.filter(pl.col("category") == category)
+
+ # The detail URL pattern on openchainbench.com is /benchmarks/.
+ # We render the title as a markdown link so clicking opens the page
+ # in a new tab.
+ pdf = (
+ df.select(
+ [
+ pl.col("title").alias("Bench"),
+ pl.col("slug"),
+ pl.col("category").alias("Category"),
+ pl.col("metric").alias("Metric"),
+ pl.col("unit").alias("Unit"),
+ pl.col("leader_name").alias("Leader"),
+ pl.col("leader_value").alias("Leader value"),
+ pl.col("bench_sample_size").alias("Sample size"),
+ pl.col("as_of").alias("As of"),
+ ]
+ )
+ .sort("Bench")
+ .to_pandas()
+ )
+ pdf["Bench"] = pdf.apply(
+ lambda r: f"[{r['Bench']}]({SITE_URL}/benchmarks/{r['slug']})", axis=1
+ )
+ pdf = pdf.drop(columns=["slug"])
+ return pdf
+
+
+def view_chain_leaders(bench: str, chain: str) -> Any:
+ df = chain_leaders_df()
+ if df.is_empty():
+ return df.to_pandas()
+ if bench and bench != "All":
+ df = df.filter(pl.col("bench_slug") == bench)
+ if chain and chain != "All":
+ df = df.filter(pl.col("chain") == chain)
+ return (
+ df.select(
+ [
+ pl.col("bench_slug").alias("Bench"),
+ pl.col("chain").alias("Chain"),
+ pl.col("leader_name").alias("Leader"),
+ pl.col("leader_value").alias("Leader value"),
+ pl.col("worst_name").alias("Worst"),
+ pl.col("worst_value").alias("Worst value"),
+ ]
+ )
+ .sort(["Bench", "Chain"])
+ .to_pandas()
+ )
+
+
+def view_providers(bench: str) -> Any:
+ df = providers_df()
+ if not bench:
+ return df.head(0).to_pandas()
+ df = df.filter(pl.col("bench_slug") == bench)
+ return (
+ df.select(
+ [
+ pl.col("provider_name").alias("Provider"),
+ pl.col("provider_type").alias("Type"),
+ pl.col("p50").alias("p50"),
+ pl.col("p90").alias("p90"),
+ pl.col("p99").alias("p99"),
+ pl.col("success_rate").alias("Success rate"),
+ pl.col("provider_sample_size").alias("Sample size"),
+ pl.col("is_leader").alias("Leader?"),
+ ]
+ )
+ .sort("p50", nulls_last=True)
+ .to_pandas()
+ )
+
+
+ABOUT_MD = f"""
+## OpenChainBench
+
+Public benchmarks for crypto infrastructure: RPCs, oracles, bridges, aggregators,
+prediction markets, and more. The full leaderboard, methodology, and per-bench
+detail live at [openchainbench.com]({SITE_URL}).
+
+This Space is a thin viewer over the daily parquet snapshot published to
+[{DATASET_REPO}]({DATASET_URL}). Every tab reads directly from the dataset, so
+the numbers you see here match the dataset exactly.
+
+### Links
+- Website: [{SITE_URL}]({SITE_URL})
+- Dataset: [{DATASET_URL}]({DATASET_URL})
+- GitHub: [{GITHUB_URL}]({GITHUB_URL})
+
+### License
+
+The dataset is released under **CC-BY-4.0**. Attribution required: link
+back to {SITE_URL} or the dataset page.
+
+### Citation
+
+```bibtex
+@misc{{openchainbench2026,
+ title = {{OpenChainBench: Public benchmarks for crypto infrastructure}},
+ author = {{OpenChainBench contributors}},
+ year = {{2026}},
+ url = {{{DATASET_URL}}},
+ note = {{CC-BY-4.0}}
+}}
+```
+"""
+
+
+def build_app() -> gr.Blocks:
+ snapshot = latest_snapshot_date()
+ title = f"OpenChainBench leaderboard ({snapshot})"
+
+ with gr.Blocks(title=title, theme=gr.themes.Soft()) as demo:
+ gr.Markdown(f"# {title}")
+ gr.Markdown(
+ "Sortable view of the daily snapshot. Click a bench title to open "
+ f"its page on {SITE_URL}."
+ )
+
+ with gr.Tabs():
+ with gr.Tab("Today's leaderboard"):
+ cat = gr.Dropdown(
+ choices=_categories(),
+ value="All",
+ label="Category",
+ )
+ table = gr.Dataframe(
+ value=view_headlines("All"),
+ interactive=False,
+ wrap=True,
+ datatype=["markdown", "str", "str", "str", "str", "number", "number", "str"],
+ )
+ cat.change(view_headlines, inputs=cat, outputs=table)
+
+ with gr.Tab("Per-chain leaders"):
+ with gr.Row():
+ bench_dd = gr.Dropdown(
+ choices=_bench_choices_for_chains(),
+ value="All",
+ label="Bench",
+ )
+ chain_dd = gr.Dropdown(
+ choices=_chain_choices(),
+ value="All",
+ label="Chain",
+ )
+ chains_table = gr.Dataframe(
+ value=view_chain_leaders("All", "All"),
+ interactive=False,
+ wrap=True,
+ )
+ bench_dd.change(
+ view_chain_leaders,
+ inputs=[bench_dd, chain_dd],
+ outputs=chains_table,
+ )
+ chain_dd.change(
+ view_chain_leaders,
+ inputs=[bench_dd, chain_dd],
+ outputs=chains_table,
+ )
+
+ with gr.Tab("Provider rankings"):
+ slugs = _bench_slugs()
+ default_slug = slugs[0] if slugs else None
+ prov_dd = gr.Dropdown(
+ choices=slugs,
+ value=default_slug,
+ label="Bench slug",
+ )
+ prov_table = gr.Dataframe(
+ value=view_providers(default_slug) if default_slug else None,
+ interactive=False,
+ wrap=True,
+ )
+ prov_dd.change(view_providers, inputs=prov_dd, outputs=prov_table)
+
+ with gr.Tab("About"):
+ gr.Markdown(ABOUT_MD)
+
+ gr.Markdown(f"---\n{FOOTER}")
+
+ return demo
+
+
+if __name__ == "__main__":
+ app = build_app()
+ app.launch(server_name="0.0.0.0", server_port=7860)
diff --git a/scripts/hf_space/requirements.txt b/scripts/hf_space/requirements.txt
new file mode 100644
index 00000000..bffab3e4
--- /dev/null
+++ b/scripts/hf_space/requirements.txt
@@ -0,0 +1,5 @@
+gradio==4.44.1
+polars>=1.41.0
+pandas==2.2.3
+pyarrow==17.0.0
+huggingface_hub==0.26.2