Skip to content

dev to main: perp-benches YAML cohort audit - #858

Closed
Flotapponnier wants to merge 80 commits into
mainfrom
dev
Closed

Flotapponnier wants to merge 80 commits into
mainfrom
dev

Conversation

@Flotapponnier

Copy link
Copy Markdown
Collaborator

Summary

Ship the perp-benches cohort audit (PR #857) from dev to main so openchainbench.com picks up:

  • perp-open-interest: cohort expanded from 4 to 11 venues (drops gmx-v2 and gains which have no live Prom data).
  • perp-volume-share: metric switched from perp_venue_volume_30d_usd (single-venue) to perp_venue_volume_24h_usd (10 venues).
  • perp-fees, perp-funding, perp-funding-stability audited, unchanged.

After merge, run vercel --prod, then rebuild the materialize-worker on the VPS to refresh KV blobs.

Test plan

Flotapponnier and others added 30 commits June 26, 2026 14:41
…740)

After dev → main reconciliation (PRs A-E), dev was hard-reset to
main. This commit re-introduces the 4 production-ready benchmarks
that lived on the old dev branch as staging-only content. They will
NOT be promoted to main until they pass staging review.

Benches:
- bridge-revenue.yml (188L, status: live) — bridge revenue tracking
- evm-quote-latency.yml (194L, status: live) — EVM aggregator quote
  latency (Mobula vs Codex vs GeckoTerminal etc.)
- solana-dex-quote-latency.yml (192L, status: live) — Solana DEX
  quote latency (Jupiter vs Mobula vs others)
- solana-tx-landing.yml (218L, status: live) — Solana tx landing
  benchmark

Companion editorial content (for SEO long-tail):
- answers/which-evm-aggregator-has-the-fastest-quote.yml
- answers/which-solana-dex-aggregator-is-the-fastest.yml
- alternatives/jupiter.yml

Workflow going forward:
- main = prod (openchainbench.com), source of truth
- dev = staging buffer (auto-deploy to staging Preview URL)
- These 4 benches live on dev. When approved, open a dedicated
  PR cherry-picking them to main.
- Future feature work: open a feature branch from dev (or main),
  PR to dev for staging review, then promote to main.

Co-authored-by: Florent Tapponnier <contact@mobula.io>
… 043)

Adds 8 venues sourced via Mobula's CEFI funding-rate aggregator (perp_venue_funding_24h_bps, freshness via perp_venue_last_refresh_unix):
- Bitget, Coinbase, Deribit, Gate, Kraken, KuCoin, Lighter, MEXC

Same stddev-over-time semantics on a 24h window, BTC/ETH/SOL split per asset. Cohort jumps from 7 to 15 venues.

Logos: 5 PNGs sourced from CoinMarketCap exchange CDN (bitget, deribit, gate, kraken, mexc); 2 SVGs from simpleicons (coinbase, kucoin); 1 SVG from simpleicons for binance (was reusing /logos/bnb.png, the chain logo, not the exchange brand).

Bench copy + manifest entries updated.
Dev was force-pushed after PR #730 and the venue support in BenchmarkFilters (materialize/filters.ts) was lost in the spec/snapshot refactor PR #739. The worker still references f.venue and dims.venue, so typecheck failed. Re-add venue across:
- BenchmarkFilters union (load.ts)
- parseFilterSig allowlist
- Spec.dimensions schema (spec-schema.ts) so YAML venue dim parses
feat(perp-funding-stability): scale to 15 venues (+8 via Mobula) + logos
…ble in dark mode)

simpleicons SVGs are monochrome (renders black) so coinbase and kucoin logos vanished in dark mode. Replace with the colored 200x200 PNGs from the CoinMarketCap exchange CDN, same source as bitget/deribit/gate/kraken/mexc.
…ored

fix(logos): coinbase + kucoin colored PNG (was invisible in dark mode)
PR #743 created src/app/benchmarks/category/[cat]/page.tsx + benchmark-grid.tsx referencing @/lib/categories but the helper module was not cherry-picked to main, breaking typecheck.

Also re-apply binance: /logos/binance.svg (was reverted by auto-merge against pre-existing main entry pointing to /logos/bnb.png).
* feat(sample-health): wire UI badge + API surface + insufficient silencing (follow-up A) (#741)

Completes PR #732 (sample-health system) by surfacing the computed
dataConfidence/sampleHealth/expectedN to users + machine-readable
consumers. Was dead-code on the data layer until this PR.

UI (ledger-table.tsx):
- Filter rows tagged 'insufficient' out of the sorted ranking.
- 'Low sample' pill on rows with dataConfidence='low' + tooltip
  showing the actual sample-health ratio ('45% of expected').

APIs:
- /api/citable: returns expectedN + dataConfidence per row. When
  bench-wide aggregate is 'insufficient', returns value=null +
  leader=null (refuses to crown a leader from undersized samples).
- /api/stat/[slug]: same treatment + drops 'insufficient' rows
  from the machine-readable ranking array. Per-row sampleHealth +
  dataConfidence exposed for downstream tooling.

Library (citation.ts):
- citationCandidates() drops 'insufficient' rows from the leader
  candidate set.
- fieldValue/leader/headlineSentence return null/'insufficient
  data' when bench-wide is insufficient.
- isInsufficient() helper preserved from main (used by hub cards +
  /api/citable hot path).

Effect: bench pages where YAML declares expected_n now visually
communicate sample confidence; agents/journalists hitting the
citable API can't accidentally quote a leader drawn from 3 data
points; the methodology page's 'n >= 1000' claim becomes honest.

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* feat(seo): /benchmarks/category/[cat] hub pages + BenchmarkGrid lockedCategory (D.2) (#743)

Final cherry-pick from dev-archive — adds 7 new SEO-indexable hub
pages (one per benchmark category) that target long-tail queries
like 'best crypto aggregator benchmarks 2026'.

D.2 was deferred from PR D because the BenchmarkCard ↔ MiniChart ↔
BenchmarkCardData type chain had cascading TS errors. Follow-up A
(citation.ts isInsufficient → InsufficientCheckInput structural
subset) silently fixed those, so this lands cleanly now.

Files:
- src/app/benchmarks/category/[cat]/page.tsx (new) — server route
  that renders BenchmarkGrid pre-filtered to one category + breadcrumb
  + ItemList JSON-LD. generateStaticParams builds the 7 hub routes
  from CATEGORIES. Each page is fully indexable (canonical, OG, etc.).
- src/components/benchmark-grid.tsx (updated) — adds two optional
  props (lockedCategory, allCategories). Backward compatible: when
  unset the grid behaves exactly as before (used on /benchmarks).
  When set (category hub routes), the grid pre-filters and shows the
  full category pill list so users can hop between hubs.

URLs that ship after deploy:
- /benchmarks/category/aggregators
- /benchmarks/category/bridges
- /benchmarks/category/blockchains
- /benchmarks/category/trading
- /benchmarks/category/wallets
- /benchmarks/category/rpcs
- /benchmarks/category/nft-apis

Each renders the canonical bench grid filtered to that category with
its own H1, meta description, and structured data. Sitemap pickup
automatic on next regen.

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* fix(seo): blacklist hex builder slugs and shared composite filter

Drop 15 anonymous 0x... HL builder slugs and 5 dead composite slugs
from /products listing, sitemap and individual /products/<slug> routes.
DEAD_COMPOSITE_SLUGS now lives in providers.ts as the single source of
truth; related-providers.ts imports it. Bumps providers cache key v3->v4.

---------

Co-authored-by: Florent Tapponnier <contact@mobula.io>
* feat(sample-health): wire UI badge + API surface + insufficient silencing (follow-up A) (#741)

Completes PR #732 (sample-health system) by surfacing the computed
dataConfidence/sampleHealth/expectedN to users + machine-readable
consumers. Was dead-code on the data layer until this PR.

UI (ledger-table.tsx):
- Filter rows tagged 'insufficient' out of the sorted ranking.
- 'Low sample' pill on rows with dataConfidence='low' + tooltip
  showing the actual sample-health ratio ('45% of expected').

APIs:
- /api/citable: returns expectedN + dataConfidence per row. When
  bench-wide aggregate is 'insufficient', returns value=null +
  leader=null (refuses to crown a leader from undersized samples).
- /api/stat/[slug]: same treatment + drops 'insufficient' rows
  from the machine-readable ranking array. Per-row sampleHealth +
  dataConfidence exposed for downstream tooling.

Library (citation.ts):
- citationCandidates() drops 'insufficient' rows from the leader
  candidate set.
- fieldValue/leader/headlineSentence return null/'insufficient
  data' when bench-wide is insufficient.
- isInsufficient() helper preserved from main (used by hub cards +
  /api/citable hot path).

Effect: bench pages where YAML declares expected_n now visually
communicate sample confidence; agents/journalists hitting the
citable API can't accidentally quote a leader drawn from 3 data
points; the methodology page's 'n >= 1000' claim becomes honest.

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* feat(seo): /benchmarks/category/[cat] hub pages + BenchmarkGrid lockedCategory (D.2) (#743)

Final cherry-pick from dev-archive — adds 7 new SEO-indexable hub
pages (one per benchmark category) that target long-tail queries
like 'best crypto aggregator benchmarks 2026'.

D.2 was deferred from PR D because the BenchmarkCard ↔ MiniChart ↔
BenchmarkCardData type chain had cascading TS errors. Follow-up A
(citation.ts isInsufficient → InsufficientCheckInput structural
subset) silently fixed those, so this lands cleanly now.

Files:
- src/app/benchmarks/category/[cat]/page.tsx (new) — server route
  that renders BenchmarkGrid pre-filtered to one category + breadcrumb
  + ItemList JSON-LD. generateStaticParams builds the 7 hub routes
  from CATEGORIES. Each page is fully indexable (canonical, OG, etc.).
- src/components/benchmark-grid.tsx (updated) — adds two optional
  props (lockedCategory, allCategories). Backward compatible: when
  unset the grid behaves exactly as before (used on /benchmarks).
  When set (category hub routes), the grid pre-filters and shows the
  full category pill list so users can hop between hubs.

URLs that ship after deploy:
- /benchmarks/category/aggregators
- /benchmarks/category/bridges
- /benchmarks/category/blockchains
- /benchmarks/category/trading
- /benchmarks/category/wallets
- /benchmarks/category/rpcs
- /benchmarks/category/nft-apis

Each renders the canonical bench grid filtered to that category with
its own H1, meta description, and structured data. Sitemap pickup
automatic on next regen.

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* fix(seo): drop dead /alternatives links from perp/PM venue footers + dead compare pair

Two SEO regressions feeding 404s into Google's crawl:

1. /products/<slug> for perp/PM venues without an alternatives YAML
   (lighter, manifold, vertex, grvt, ostium, variational, pacifica,
   aevo, edgex, paradex, extended, myriad, limitless, aster) hardcoded
   a /alternatives/<slug> footer link in PmVenueSection and
   PerpVenueSection. Now gated on loadAlternativeSlugs() membership so
   only venues with an actual YAML emit the link.

2. /compare hub rendered jupiter-vs-raydium even though raydium has
   no bench appearances, so /compare/jupiter-vs-raydium 404s
   downstream. Pre-filter COMPARE_PAIRS against getProvider() so dead
   pairs disappear from the list and the JSON-LD ItemList until both
   sides resolve.

Plus a defensive guard in buildAlternativesReverseMap() so the
emitted alt.slug is always a known live YAML, even if the alt list
source ever changes shape.

---------

Co-authored-by: Florent Tapponnier <contact@mobula.io>
Co-authored-by: Florent Tapponnier <contact@mobula.io>
chore(dev): sync main → dev (bring SEO hotfixes + canonical fixes)
…s to sitemap (#764)

Co-authored-by: Florent Tapponnier <contact@mobula.io>
…ge (#766)

Drop in #701 (2026-06-25) was driven by harness offline + 404 SEO. The Railway harness still runs and Prom keeps emitting solana_landing_probe_enabled=1 plus the slot/latency series, so restoring the YAML brings the page back live (or in 'awaiting samples' until a funded SOLANA_PROBE_KEYPAIR_BASE58 is set on Railway). Validate clean (34 specs).
When a chain rename rolls through Prom while the 24h window still holds samples on the legacy label (e.g. ton → gram during the harness redeploy lag), the loader rewrote each ton row to gram but did not dedupe — so /benchmarks/l1-finality rendered Gram twice (one row per source series).

Two-pass: rewrite then dedupe by canonical slug, preferring the row whose original slug already matched the canonical (current harness emit) over the alias-rewritten historical row.
fix(spec): dedupe results after legacy chain slug rewrite
Numeric column headers in LedgerTable now toggle the table's sort
on click. First click on a header descends on its value, second
click toggles to ascending. An up/down chevron next to the active
header's label marks the direction; hover lifts ink-muted to ink
so the headers read as interactive.

Sortable columns: Name (A-Z), every customCols entry (per-index
key), Value (when a panel tab is active), p50/p90/p99/Mean,
Delta field, Success, and Slot delta p50 (when hasSlots). Trend
and Number columns stay static.

The default sort (sortKey null) preserves the exact prior
comparator so OG renders, share cards, and snapshot tests keep
matching. A non-null sortKey routes through a new pickSortValue
helper instead. Missing values land at +/-Infinity so empty cells
consistently sort to the bottom regardless of direction.
feat(ledger): clickable column headers to sort leaderboard
feat(hl-frontends): long-window archive UI toggle + history API
…dev-hl-frontends

# Conflicts:
#	src/components/ledger-table.tsx
…rontends

chore(dev): sync main → dev (HL frontends + cache bumps)
…ab click (#777)

Co-authored-by: Florent Tapponnier <contact@mobula.io>
…i/series (#779)

Co-authored-by: Florent Tapponnier <contact@mobula.io>
The shared Upstash free-tier KV hit its 500k/day request cap; the Go service's PushUpstash silently failed with 'max requests limit exceeded' and the bench page stayed on 'archive_pending' for the long windows.

Swap the reader to call the hl-archive Railway HTTPS API (/v1/aggregates?window=all) directly. Same payload shape, one less moving part, no quota constraint, no JSON-encoded-string detour. Next.js unstable_cache (60s revalidate) still bounds the per-region request load to the Railway service.

Two new env vars on Vercel:
- HL_ARCHIVE_API_URL (e.g. https://hl-archive-production.up.railway.app)
- HL_ARCHIVE_API_KEY (matches the Go service's HL_ARCHIVE_API_KEY)

Existing Upstash env (KV_REST_API_URL / UPSTASH_*) is no longer read by this module.
feat(hl-archive): read directly from Railway service (skip Upstash)
Fold the standalone HlHistoryToggle into the chart pills so there is a
single source of truth for the active window. Short windows (1H..30D)
keep the existing Prom-sourced chart + ledger. Long windows (90D..ALL)
swap the chart data source to the archive's per-builder daily timeseries
and replace the ledger with the archive-ranked leaderboard.

- chart pills extended with 90D 180D 1Y ALL behind a hairline separator
- archive fetch lifted to BenchmarkBody, payload reused for chart + ledger
- /history API now passes through timeseries_daily on archive responses
- HlHistoryToggle removed, behaviour split into HlArchiveLeaderboard +
  the chart's new longRangeSeries prop
- long-range pills disable with a 'soon' hint when the archive is down
Flotapponnier and others added 28 commits July 1, 2026 17:01
…airs (#810)

Co-authored-by: Florent Tapponnier <contact@mobula.io>
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* fix(seo): cap meta descriptions <=155 chars for SERP snippet

* fix(seo): add og:image + twitter:image to /answers pages

---------

Co-authored-by: Florent Tapponnier <contact@mobula.io>
Co-authored-by: Florent Tapponnier <contact@mobula.io>
Co-authored-by: Florent Tapponnier <contact@mobula.io>
…ectionPage) (#819)

Co-authored-by: Florent Tapponnier <contact@mobula.io>
…lax sitemap filter (#821)

Co-authored-by: Florent Tapponnier <contact@mobula.io>
Co-authored-by: Florent Tapponnier <contact@mobula.io>
…de + neutron gas-only (#825)

EVM chains (ethereum, bnb, avalanche, polygon, arbitrum, optimism,
base, blast, mantle, opbnb, celo, scroll, linea) were driven by a
placeholder init bytecode that undercounted the real OZ v5 ERC20
deploy cost by 30 to 500x depending on chain. Removed from the
leaderboard until the canonical OZ v5 artifact is finalised.

Neutron TokenFactory has no protocol fee (gas-only) which was not
comparable to a real token deploy on other chains, so it is removed
too to keep the comparison consistent.

Kept 7 non-EVM L1 chains: solana, sui, aptos, osmosis, injective,
cardano, stellar.

Co-authored-by: Florent Tapponnier <contact@mobula.io>
Avalanche, Stellar, Gram (TON), Optimism, Scroll and Blast were
producing headline USD figures 100 to 1000x too low because the
per chain fee fetcher only counted base_fee without priority or
misapplied the wei/gwei conversion. Removed from the leaderboard
until the fetcher is fixed to avoid publishing false values that
would discredit the whole bench.

Kept 14 chains with coherent numbers: ethereum, bnb, solana, tron,
cardano, sui, litecoin, monero, arbitrum, base, zksync, linea,
mantle, taiko.

Co-authored-by: Florent Tapponnier <contact@mobula.io>
fix(ledger): use metric label + single Value column for non-latency benches
…831)

Both hub pages read Prom directly and broke when PROMETHEUS_URL was
dropped from Vercel after the blob-only migration.

- fetchPmCohortFresh / fetchChainKpisFresh: uncached Prom paths for the
  worker.
- fetchPmCohort / fetchChainKpis: snapshot-first readers (Upstash blob,
  fall through to Prom + writeback, unstable_cache 60s).
- worker: writes pm-hub + one chain-kpis:<slug> blob per registered chain
  every tierA sweep, alongside the existing hl-frontends / perp-cohort
  jobs.

Co-authored-by: Florent Tapponnier <contact@mobula.io>
…d via KV blob (#833)

Co-authored-by: Florent Tapponnier <contact@mobula.io>
…835)

Co-authored-by: Florent Tapponnier <contact@mobula.io>
…#837)

* fix(perf): strip 7d/30d series from cached Benchmark, lazy-fetch on tab click (#777) (#778)

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* perf(chart): pre-fetch 7d/30d series on mount + add app-cache for /api/series (#779) (#780)

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* chore(prom): enable admin api for TSDB snapshot transfer to VPS (#781)

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* Revert "chore(prom): enable admin api for TSDB snapshot transfer to VPS" (#782)

This reverts commit cce0436.

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* chore(harnesses): sync from mobula-api production (8 harnesses, 21 files) (#784)

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* feat(harnesses): port 6 harnesses from mobula-api to OCB public mirror (#785)

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* fix(polymarket-resolution-delay): convert query output to ms for fmtUnit

The harness publishes a histogram in seconds (pmres_resolution_delay_seconds_bucket per Prom convention). fmtUnit for unit=s expects the value in milliseconds (the project convention used by l1-finality and aggregator-head-lag, where the histogram is *_milliseconds_bucket).

Result: the bench's actual p50 of 679s (11.3 min) for crypto was rendered as 0.7s on the share card / OG image / leaderboard. Wrap every histogram_quantile with 1000 * so the value arrives in ms and the auto-flip to min/h kicks in correctly.

No code change in fmtUnit — keeps the convention consistent across all s-unit benches.

* fix(gas-estimation): drop Blocknative from SupportedSet (API sunsetted) (#789)

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* fix: filter region tabs to regions with actual data (#791)

bench pages declared regions (sgp/us-east/eu-west) that the harness
never emitted data for, so clicking the tab 404'd silently and the
chart fell back to the previous variant. now we cross-check declared
regions against extras.regions + seriesByRegion24h and only keep the
ones that have samples.

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* chore: trigger vercel prod rebuild after HL_ARCHIVE_API_URL switch

* feat(hl-frontends): long-window archive UI toggle + history API

* feat(hl-archive): read directly from Railway service (skip Upstash)

The shared Upstash free-tier KV hit its 500k/day request cap; the Go service's PushUpstash silently failed with 'max requests limit exceeded' and the bench page stayed on 'archive_pending' for the long windows.

Swap the reader to call the hl-archive Railway HTTPS API (/v1/aggregates?window=all) directly. Same payload shape, one less moving part, no quota constraint, no JSON-encoded-string detour. Next.js unstable_cache (60s revalidate) still bounds the per-region request load to the Railway service.

Two new env vars on Vercel:
- HL_ARCHIVE_API_URL (e.g. https://hl-archive-production.up.railway.app)
- HL_ARCHIVE_API_KEY (matches the Go service's HL_ARCHIVE_API_KEY)

Existing Upstash env (KV_REST_API_URL / UPSTASH_*) is no longer read by this module.

* feat(hyperliquid-frontends): unify chart + leaderboard window selector

Fold the standalone HlHistoryToggle into the chart pills so there is a
single source of truth for the active window. Short windows (1H..30D)
keep the existing Prom-sourced chart + ledger. Long windows (90D..ALL)
swap the chart data source to the archive's per-builder daily timeseries
and replace the ledger with the archive-ranked leaderboard.

- chart pills extended with 90D 180D 1Y ALL behind a hairline separator
- archive fetch lifted to BenchmarkBody, payload reused for chart + ledger
- /history API now passes through timeseries_daily on archive responses
- HlHistoryToggle removed, behaviour split into HlArchiveLeaderboard +
  the chart's new longRangeSeries prop
- long-range pills disable with a 'soon' hint when the archive is down

* chore: trigger vercel prod redeploy

* fix: retry lazy 7d/30d fetch instead of caching empty {} forever

the time-series chart pre-fetches 7d/30d data on mount via /api/series and
stores it in component state. the previous catch path set the state to {}
on any failure, and the guard treated {} as 'already fetched' because
empty objects are truthy. trigger: prom returned 503 for ~30 min during
a tsdb backfill ingestion, every chart that mounted in that window cached
{} and never refetched even after recovery.

now: track 'done' in a local ref scoped to the effect run, retry the
catch path twice (2s then 4s) before committing to the empty placeholder,
and drop the lazy state from the effect's deps so success doesn't
trigger a re-render → re-run loop.

* fix(logos): swap binance to colored PNG (simpleicons SVG was invisible in dark mode)

Same monochrome-black issue we hit with coinbase + kucoin. Replace with the 200x200 colored PNG from the CoinMarketCap exchange CDN, same source as the other exchange logos in the manifest.

* fix(api/series): read 7d/30d from worker blob before live Prom fan-out (#798)

getSeriesMapCached was calling specToBenchmark on every cold miss, which
fans out 30-50 per-provider Prom queries. Under load (Vercel ISR
re-renders, many concurrent functions) those queue at Prom's concurrency
cap and time out the 60s Vercel function, returning 404 'no_data_for_range'
to the chart pre-fetch.

The worker already writes series7d / series30d into the per-(slug, sig)
blob. Read that first via readMaterialized() and only fall through to
specToBenchmark when the blob is missing (newly-deployed bench) or empty
for this variant (worker hasn't covered it yet). Cache key bumped to v2
so the old cached entries built off live Prom don't get reused.

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* feat(spec): blob-only Vercel render path (#800)

The worker is now the sole Prometheus consumer; Vercel renders read
the worker-published snapshots and never query Prom at render time.

What changes:
- benchFromStore: drop STORE_MAX_AGE_MS cutoff. A stale blob is always
  preferred over a render-time Prom fan-out, which is what saturated
  Prom's query queue under Vercel ISR re-render bursts (1000+ RPS
  cascade triggered by a single cold-cache visit to a hub page).
- loadBenchmarkUnfilteredCached: drop the specToBenchmark fallback.
  Missing or unparseable blobs return undefined so aggregateBenchmarks
  substitutes a draft placeholder and the page still renders.
- loadBenchmarkFiltered: same treatment. A variant blob the worker
  hasn't covered yet falls back to the unfiltered All view at the
  loadBenchmark layer.
- Drop now-unused imports (buildEditorial, readSnapshot,
  snapshotFromBenchmark, writeSnapshot, specToBenchmark,
  parseFilterSig, MS_PER_MINUTE).

Failure modes:
- Worker stalls / Prom outage: snapshots age, charts keep rendering
  with stale data. No 504s, no spinner-then-empty cascade.
- New bench deployed before first sweep: draft placeholder until the
  worker covers it (~60s on tier-A cadence).
- Variant not yet warmed: filtered loader returns undefined, caller
  drops to the unfiltered aggregate view (same UX as today's draft
  placeholder, but without paying the 30-50 query Prom roundtrip).

The READ_FROM_STORE env flag is preserved as a kill switch — unset
it to disable blob reads, every render then returns a draft
placeholder. Useful for cold-bootstrapping a new KV.

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* feat: move cohort/featured writers from Vercel crons into the worker (#802)

The worker now owns every Prom-derived blob: per-bench snapshots
(tier-A / tier-B), the hub cohort blobs (perp-cohort, hl-frontends,
hl-hip3), and the search dialog's featured-leaders blob. Vercel
crons that used to do these writes are deleted.

Why: the Vercel cron path forced one render-time Prom client per
invocation, hitting Prom through the public Caddy route. With that
route removed (and PROMETHEUS_URL gone from the Vercel env) the
crons would 502 silently and the cohort blobs would expire after
their 24h TTL, breaking /perps, /hyperliquid, and the search dialog.
The worker already holds the in-network Prom URL and the same writer
functions, so colocating them there is the obvious end state.

Changes:
- worker/index.ts: after each tier-A pass, run perp-cohort,
  hl-frontends, hl-hip3, search-featured builders in parallel and
  write each blob via writeCohortSnapshot. Failures are isolated
  per blob.
- src/lib/prometheus.ts: allow plain http for in-network hostnames
  (ocb-prom / localhost / 127.0.0.1) — both in the constructor's
  protocol gate and in the SSRF guard. Vercel renders never reach
  here anymore (blob-only) so the relaxation is scoped to the
  worker process running on the VPS.
- vercel.json: drop the snapshot-perp-cohort, snapshot-hl-cohort,
  warm-search-featured cron entries.
- Delete the three /api/cron/*/route.ts handlers.

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* fix(worker): use uncached buildFeaturedLeadersFromStore for the search blob (#804)

The previous tier-A cohort sweep called buildFeaturedLeaders which
fetches all benches through getBenchmarks → unstable_cache + React
cache. Outside a Next request lifecycle (i.e. from the standalone
worker tsx process) that throws 'Invariant: incrementalCache missing'
on every sweep, leaving the search-featured blob un-refreshed.

Adds buildFeaturedLeadersFromStore: reads the 12 featured/trending
bench blobs directly from KV via readMaterialized. Same output shape,
same writeCohortSnapshot envelope, no Next runtime needed.

Verified locally: tsc clean. Will verify post-deploy that the worker
logs cohort done in X.Xs (4/4 ok) instead of (3/4 ok).

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* chore(live): point relay to stream.openchainbench.com

* fix(csp): allow wss://stream.openchainbench.com in connect-src

* hotfix: SEO titles + compare 500 (cherry-pick from dev) (#811)

* fix(seo): shorten titles to <=60 chars for SERP display (#806)

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* fix(compare): force-dynamic to bypass static→dynamic bail on ad-hoc pairs (#810)

Co-authored-by: Florent Tapponnier <contact@mobula.io>

---------

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* hotfix: meta descriptions + /answers og:image (cherry-pick) (#814)

* fix(seo): cap meta descriptions <=155 chars for SERP snippet (#812)

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* fix(seo): add og:image + twitter:image to /answers pages (#813)

* fix(seo): cap meta descriptions <=155 chars for SERP snippet

* fix(seo): add og:image + twitter:image to /answers pages

---------

Co-authored-by: Florent Tapponnier <contact@mobula.io>

---------

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* perf(compare): restore ISR on ad-hoc pairs (fix slow TTFB) (#815) (#816)

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* feat(sitemap): emit ad-hoc compare pairs + category hubs (#817) (#818)

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* fix(seo): drop invalid rich-result schemas (SoftwareApplication, CollectionPage) (#819) (#820)

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* fix(seo): shorten product titles + strip markdown from meta desc + relax sitemap filter (#821) (#822)

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* feat(seo): contextual compare links on bench pages (#823) (#824)

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* fix(token-deployment-cost): remove EVM chains with placeholder bytecode + neutron gas-only (#825) (#826)

EVM chains (ethereum, bnb, avalanche, polygon, arbitrum, optimism,
base, blast, mantle, opbnb, celo, scroll, linea) were driven by a
placeholder init bytecode that undercounted the real OZ v5 ERC20
deploy cost by 30 to 500x depending on chain. Removed from the
leaderboard until the canonical OZ v5 artifact is finalised.

Neutron TokenFactory has no protocol fee (gas-only) which was not
comparable to a real token deploy on other chains, so it is removed
too to keep the comparison consistent.

Kept 7 non-EVM L1 chains: solana, sui, aptos, osmosis, injective,
cardano, stellar.

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* fix(network-fees): remove 6 chains + drop from harness config (cherry pick #827) (#828)

* fix(network-fees): remove 6 chains with buggy fee calculation (#827)

Avalanche, Stellar, Gram (TON), Optimism, Scroll and Blast were
producing headline USD figures 100 to 1000x too low because the
per chain fee fetcher only counted base_fee without priority or
misapplied the wei/gwei conversion. Removed from the leaderboard
until the fetcher is fixed to avoid publishing false values that
would discredit the whole bench.

Kept 14 chains with coherent numbers: ethereum, bnb, solana, tron,
cardano, sui, litecoin, monero, arbitrum, base, zksync, linea,
mantle, taiko.

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* fix(transaction-fee): drop 6 chains from harness config

Companion to the network-fees YAML clean up: stop scraping the six
chains whose per chain fee fetcher produced values 100 to 1000x too
low (avalanche, stellar, ton, optimism, scroll, blast). The harness
now only samples the 14 chains kept on the leaderboard, which cuts
one wasted goroutine + Mobula slug per removed chain.

---------

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* fix(ledger): use metric label + single Value column for non-latency benches

* feat(hub): migrate /prediction-markets and /chains KPIs to KV blobs (#832)

Both hub pages read Prom directly and broke when PROMETHEUS_URL was
dropped from Vercel after the blob-only migration.

- fetchPmCohortFresh / fetchChainKpisFresh: uncached Prom paths for the
  worker.
- fetchPmCohort / fetchChainKpis: snapshot-first readers (Upstash blob,
  fall through to Prom + writeback, unstable_cache 60s).
- worker: writes pm-hub + one chain-kpis:<slug> blob per registered chain
  every tierA sweep, alongside the existing hl-frontends / perp-cohort
  jobs.

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* feat(hl-history): 12-month rolling fees + volume chart on /hyperliquid via KV blob (#833)

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* feat(hl-history): compact format + all frontends (98) in single blob (#835) (#836)

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* feat(hl-history): switch Y axis to log scale for 12-month readability

---------

Co-authored-by: Florent Tapponnier <contact@mobula.io>
Co-authored-by: Florent Tapponnier <contact@mobula.io>
…s/{hl-frontend} to /hyperliquid/{slug} (#841)

Co-authored-by: Florent Tapponnier <contact@mobula.io>
feat(hl-frontend): restore rich dashboard on /hyperliquid/[slug]
Co-authored-by: Florent Tapponnier <contact@mobula.io>
* fix(perf): strip 7d/30d series from cached Benchmark, lazy-fetch on tab click (#777) (#778)

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* perf(chart): pre-fetch 7d/30d series on mount + add app-cache for /api/series (#779) (#780)

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* chore(prom): enable admin api for TSDB snapshot transfer to VPS (#781)

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* Revert "chore(prom): enable admin api for TSDB snapshot transfer to VPS" (#782)

This reverts commit cce0436.

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* chore(harnesses): sync from mobula-api production (8 harnesses, 21 files) (#784)

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* feat(harnesses): port 6 harnesses from mobula-api to OCB public mirror (#785)

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* fix(polymarket-resolution-delay): convert query output to ms for fmtUnit

The harness publishes a histogram in seconds (pmres_resolution_delay_seconds_bucket per Prom convention). fmtUnit for unit=s expects the value in milliseconds (the project convention used by l1-finality and aggregator-head-lag, where the histogram is *_milliseconds_bucket).

Result: the bench's actual p50 of 679s (11.3 min) for crypto was rendered as 0.7s on the share card / OG image / leaderboard. Wrap every histogram_quantile with 1000 * so the value arrives in ms and the auto-flip to min/h kicks in correctly.

No code change in fmtUnit — keeps the convention consistent across all s-unit benches.

* fix(gas-estimation): drop Blocknative from SupportedSet (API sunsetted) (#789)

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* fix: filter region tabs to regions with actual data (#791)

bench pages declared regions (sgp/us-east/eu-west) that the harness
never emitted data for, so clicking the tab 404'd silently and the
chart fell back to the previous variant. now we cross-check declared
regions against extras.regions + seriesByRegion24h and only keep the
ones that have samples.

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* chore: trigger vercel prod rebuild after HL_ARCHIVE_API_URL switch

* feat(hl-frontends): long-window archive UI toggle + history API

* feat(hl-archive): read directly from Railway service (skip Upstash)

The shared Upstash free-tier KV hit its 500k/day request cap; the Go service's PushUpstash silently failed with 'max requests limit exceeded' and the bench page stayed on 'archive_pending' for the long windows.

Swap the reader to call the hl-archive Railway HTTPS API (/v1/aggregates?window=all) directly. Same payload shape, one less moving part, no quota constraint, no JSON-encoded-string detour. Next.js unstable_cache (60s revalidate) still bounds the per-region request load to the Railway service.

Two new env vars on Vercel:
- HL_ARCHIVE_API_URL (e.g. https://hl-archive-production.up.railway.app)
- HL_ARCHIVE_API_KEY (matches the Go service's HL_ARCHIVE_API_KEY)

Existing Upstash env (KV_REST_API_URL / UPSTASH_*) is no longer read by this module.

* feat(hyperliquid-frontends): unify chart + leaderboard window selector

Fold the standalone HlHistoryToggle into the chart pills so there is a
single source of truth for the active window. Short windows (1H..30D)
keep the existing Prom-sourced chart + ledger. Long windows (90D..ALL)
swap the chart data source to the archive's per-builder daily timeseries
and replace the ledger with the archive-ranked leaderboard.

- chart pills extended with 90D 180D 1Y ALL behind a hairline separator
- archive fetch lifted to BenchmarkBody, payload reused for chart + ledger
- /history API now passes through timeseries_daily on archive responses
- HlHistoryToggle removed, behaviour split into HlArchiveLeaderboard +
  the chart's new longRangeSeries prop
- long-range pills disable with a 'soon' hint when the archive is down

* chore: trigger vercel prod redeploy

* fix: retry lazy 7d/30d fetch instead of caching empty {} forever

the time-series chart pre-fetches 7d/30d data on mount via /api/series and
stores it in component state. the previous catch path set the state to {}
on any failure, and the guard treated {} as 'already fetched' because
empty objects are truthy. trigger: prom returned 503 for ~30 min during
a tsdb backfill ingestion, every chart that mounted in that window cached
{} and never refetched even after recovery.

now: track 'done' in a local ref scoped to the effect run, retry the
catch path twice (2s then 4s) before committing to the empty placeholder,
and drop the lazy state from the effect's deps so success doesn't
trigger a re-render → re-run loop.

* fix(logos): swap binance to colored PNG (simpleicons SVG was invisible in dark mode)

Same monochrome-black issue we hit with coinbase + kucoin. Replace with the 200x200 colored PNG from the CoinMarketCap exchange CDN, same source as the other exchange logos in the manifest.

* fix(api/series): read 7d/30d from worker blob before live Prom fan-out (#798)

getSeriesMapCached was calling specToBenchmark on every cold miss, which
fans out 30-50 per-provider Prom queries. Under load (Vercel ISR
re-renders, many concurrent functions) those queue at Prom's concurrency
cap and time out the 60s Vercel function, returning 404 'no_data_for_range'
to the chart pre-fetch.

The worker already writes series7d / series30d into the per-(slug, sig)
blob. Read that first via readMaterialized() and only fall through to
specToBenchmark when the blob is missing (newly-deployed bench) or empty
for this variant (worker hasn't covered it yet). Cache key bumped to v2
so the old cached entries built off live Prom don't get reused.

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* feat(spec): blob-only Vercel render path (#800)

The worker is now the sole Prometheus consumer; Vercel renders read
the worker-published snapshots and never query Prom at render time.

What changes:
- benchFromStore: drop STORE_MAX_AGE_MS cutoff. A stale blob is always
  preferred over a render-time Prom fan-out, which is what saturated
  Prom's query queue under Vercel ISR re-render bursts (1000+ RPS
  cascade triggered by a single cold-cache visit to a hub page).
- loadBenchmarkUnfilteredCached: drop the specToBenchmark fallback.
  Missing or unparseable blobs return undefined so aggregateBenchmarks
  substitutes a draft placeholder and the page still renders.
- loadBenchmarkFiltered: same treatment. A variant blob the worker
  hasn't covered yet falls back to the unfiltered All view at the
  loadBenchmark layer.
- Drop now-unused imports (buildEditorial, readSnapshot,
  snapshotFromBenchmark, writeSnapshot, specToBenchmark,
  parseFilterSig, MS_PER_MINUTE).

Failure modes:
- Worker stalls / Prom outage: snapshots age, charts keep rendering
  with stale data. No 504s, no spinner-then-empty cascade.
- New bench deployed before first sweep: draft placeholder until the
  worker covers it (~60s on tier-A cadence).
- Variant not yet warmed: filtered loader returns undefined, caller
  drops to the unfiltered aggregate view (same UX as today's draft
  placeholder, but without paying the 30-50 query Prom roundtrip).

The READ_FROM_STORE env flag is preserved as a kill switch — unset
it to disable blob reads, every render then returns a draft
placeholder. Useful for cold-bootstrapping a new KV.

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* feat: move cohort/featured writers from Vercel crons into the worker (#802)

The worker now owns every Prom-derived blob: per-bench snapshots
(tier-A / tier-B), the hub cohort blobs (perp-cohort, hl-frontends,
hl-hip3), and the search dialog's featured-leaders blob. Vercel
crons that used to do these writes are deleted.

Why: the Vercel cron path forced one render-time Prom client per
invocation, hitting Prom through the public Caddy route. With that
route removed (and PROMETHEUS_URL gone from the Vercel env) the
crons would 502 silently and the cohort blobs would expire after
their 24h TTL, breaking /perps, /hyperliquid, and the search dialog.
The worker already holds the in-network Prom URL and the same writer
functions, so colocating them there is the obvious end state.

Changes:
- worker/index.ts: after each tier-A pass, run perp-cohort,
  hl-frontends, hl-hip3, search-featured builders in parallel and
  write each blob via writeCohortSnapshot. Failures are isolated
  per blob.
- src/lib/prometheus.ts: allow plain http for in-network hostnames
  (ocb-prom / localhost / 127.0.0.1) — both in the constructor's
  protocol gate and in the SSRF guard. Vercel renders never reach
  here anymore (blob-only) so the relaxation is scoped to the
  worker process running on the VPS.
- vercel.json: drop the snapshot-perp-cohort, snapshot-hl-cohort,
  warm-search-featured cron entries.
- Delete the three /api/cron/*/route.ts handlers.

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* fix(worker): use uncached buildFeaturedLeadersFromStore for the search blob (#804)

The previous tier-A cohort sweep called buildFeaturedLeaders which
fetches all benches through getBenchmarks → unstable_cache + React
cache. Outside a Next request lifecycle (i.e. from the standalone
worker tsx process) that throws 'Invariant: incrementalCache missing'
on every sweep, leaving the search-featured blob un-refreshed.

Adds buildFeaturedLeadersFromStore: reads the 12 featured/trending
bench blobs directly from KV via readMaterialized. Same output shape,
same writeCohortSnapshot envelope, no Next runtime needed.

Verified locally: tsc clean. Will verify post-deploy that the worker
logs cohort done in X.Xs (4/4 ok) instead of (3/4 ok).

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* chore(live): point relay to stream.openchainbench.com

* fix(csp): allow wss://stream.openchainbench.com in connect-src

* hotfix: SEO titles + compare 500 (cherry-pick from dev) (#811)

* fix(seo): shorten titles to <=60 chars for SERP display (#806)

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* fix(compare): force-dynamic to bypass static→dynamic bail on ad-hoc pairs (#810)

Co-authored-by: Florent Tapponnier <contact@mobula.io>

---------

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* hotfix: meta descriptions + /answers og:image (cherry-pick) (#814)

* fix(seo): cap meta descriptions <=155 chars for SERP snippet (#812)

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* fix(seo): add og:image + twitter:image to /answers pages (#813)

* fix(seo): cap meta descriptions <=155 chars for SERP snippet

* fix(seo): add og:image + twitter:image to /answers pages

---------

Co-authored-by: Florent Tapponnier <contact@mobula.io>

---------

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* perf(compare): restore ISR on ad-hoc pairs (fix slow TTFB) (#815) (#816)

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* feat(sitemap): emit ad-hoc compare pairs + category hubs (#817) (#818)

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* fix(seo): drop invalid rich-result schemas (SoftwareApplication, CollectionPage) (#819) (#820)

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* fix(seo): shorten product titles + strip markdown from meta desc + relax sitemap filter (#821) (#822)

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* feat(seo): contextual compare links on bench pages (#823) (#824)

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* fix(token-deployment-cost): remove EVM chains with placeholder bytecode + neutron gas-only (#825) (#826)

EVM chains (ethereum, bnb, avalanche, polygon, arbitrum, optimism,
base, blast, mantle, opbnb, celo, scroll, linea) were driven by a
placeholder init bytecode that undercounted the real OZ v5 ERC20
deploy cost by 30 to 500x depending on chain. Removed from the
leaderboard until the canonical OZ v5 artifact is finalised.

Neutron TokenFactory has no protocol fee (gas-only) which was not
comparable to a real token deploy on other chains, so it is removed
too to keep the comparison consistent.

Kept 7 non-EVM L1 chains: solana, sui, aptos, osmosis, injective,
cardano, stellar.

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* fix(network-fees): remove 6 chains + drop from harness config (cherry pick #827) (#828)

* fix(network-fees): remove 6 chains with buggy fee calculation (#827)

Avalanche, Stellar, Gram (TON), Optimism, Scroll and Blast were
producing headline USD figures 100 to 1000x too low because the
per chain fee fetcher only counted base_fee without priority or
misapplied the wei/gwei conversion. Removed from the leaderboard
until the fetcher is fixed to avoid publishing false values that
would discredit the whole bench.

Kept 14 chains with coherent numbers: ethereum, bnb, solana, tron,
cardano, sui, litecoin, monero, arbitrum, base, zksync, linea,
mantle, taiko.

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* fix(transaction-fee): drop 6 chains from harness config

Companion to the network-fees YAML clean up: stop scraping the six
chains whose per chain fee fetcher produced values 100 to 1000x too
low (avalanche, stellar, ton, optimism, scroll, blast). The harness
now only samples the 14 chains kept on the leaderboard, which cuts
one wasted goroutine + Mobula slug per removed chain.

---------

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* fix(ledger): use metric label + single Value column for non-latency benches

* feat(hub): migrate /prediction-markets and /chains KPIs to KV blobs (#832)

Both hub pages read Prom directly and broke when PROMETHEUS_URL was
dropped from Vercel after the blob-only migration.

- fetchPmCohortFresh / fetchChainKpisFresh: uncached Prom paths for the
  worker.
- fetchPmCohort / fetchChainKpis: snapshot-first readers (Upstash blob,
  fall through to Prom + writeback, unstable_cache 60s).
- worker: writes pm-hub + one chain-kpis:<slug> blob per registered chain
  every tierA sweep, alongside the existing hl-frontends / perp-cohort
  jobs.

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* feat(hl-history): 12-month rolling fees + volume chart on /hyperliquid via KV blob (#833)

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* feat(hl-history): compact format + all frontends (98) in single blob (#835) (#836)

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* feat(hl-history): switch Y axis to log scale for 12-month readability (#838)

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* feat(hl-history): sparkline grid + per-frontend detail pages (#839) (#840)

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* feat(hl-history): move sparklines into leaderboard, redirect /products/{hl-frontend} to /hyperliquid/{slug} (#841) (#842)

Co-authored-by: Florent Tapponnier <contact@mobula.io>

* feat(hl-frontend): restore rich dashboard on /hyperliquid/[slug]

* fix(hl-frontend): remove 12-month evolution section from hub and detail pages

---------

Co-authored-by: Florent Tapponnier <contact@mobula.io>
feat(hl-perf-chart): add 30d/90d/1y range picker with DuckDB backfill
…users data (#852)

Co-authored-by: Florent Tapponnier <contact@mobula.io>
Parse new users field from hl-archive /v1/aggregates payload and
forward it on rows returned by the hyperliquid-frontends history
route. Bumps the unstable_cache key so stale snapshots are dropped.
hl-archive: expose daily unique-users field
perp-open-interest: swap gmx-v2 and gains (no live data) for the 11
venues actually scraped by perp-cohort-stats (hyperliquid, aster,
lighter, grvt, extended, ostium, pacifica, vertex, dydx, aevo, paradex).

perp-volume-share: switch metric from perp_venue_volume_30d_usd (only
vertex exposes it) to perp_venue_volume_24h_usd, which 10 venues
report cleanly. Rename bench headline to 24h rolling.

perp-fees, perp-funding, perp-funding-stability audited: cohort
matches live Prom, no change.

Co-authored-by: Florent Tapponnier <contact@mobula.io>
@Flotapponnier

Copy link
Copy Markdown
Collaborator Author

Repo uses cherry-pick to main. Opening dedicated cherry-pick branch instead.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant