Skip to content

bridge-monitor: Mobula demo-api host - #1142

Merged
Flotapponnier merged 1 commit into
devfrom
feat/mobula-demo-api
Jul 13, 2026
Merged

bridge-monitor: Mobula demo-api host#1142
Flotapponnier merged 1 commit into
devfrom
feat/mobula-demo-api

Conversation

@Flotapponnier

Copy link
Copy Markdown
Collaborator

Per the Mobula team, bridge/portfolio/market calls move from api.mobula.io to demo-api.mobula.io (9 URLs across quote, status, routes, tx executor, balance checker, pricer, slack snapshot). Verified live: the sub-$7 Arb to Sol quote that fails on the prod host succeeds on demo-api.

@Flotapponnier
Flotapponnier merged commit 548d109 into dev Jul 13, 2026
Flotapponnier added a commit that referenced this pull request Jul 25, 2026
* solana cohort: solana-official logo alias, leorpc logo + light chip, registry entries

* nft harness: keyless OpenSea mode, empty x-api-key header caused 401s (#1136)

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

* nft harness: delist rarible, free tier is 100 requests per month (#1137)

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

* robinhood chain: chains hub entry, chain-kpis, l2-block-time (HTTP poll mode), network-fees

* bridge-monitor: on-chain balance fallback + degraded flag

* bridge-monitor: self-healing metrics + slack notifiers

* bridge-monitor: auto-rebalancer, corrective transfer via cheapest bridge

* bridge-monitor: tier downgrade ladder + rebalancer wiring in pre-flight

* bridge-monitor: stuck-fund reaper, hourly stranded gauge + corrective transfer

* bridge-monitor: gas auto-top-up via LiFi same-chain swap, gated by default

* bridge-monitor: unit tests for refill route, ladder, stranded hours; fix tracker reset

* bridge-monitor: keyless dry-run support for reaper + RunDryRun

* bridge-monitor: comment style cleanup in pre-flight docs

* bridge-monitor: daily spend tracker keyed by UTC date, persisted; execution mutex primitive

DailySpentUSD only ever incremented and was zeroed by every restart, so a
crash-loop minted a fresh budget each time. The tracker resets exactly at
UTC midnight, persists (date, spent) to SPEND_STATE_PATH (default
./spend-state.json, not /tmp) and is mutex-guarded. Also declares the
package-level single-flight lock and the FAILED_TX_FEE_ESTIMATE_USD
(default 0.50) helper for gas bled by failed broadcasts.

* bridge-monitor: fix double-send on in-flight rebalance, slot-wide budget, single-flight lock

Fund-loss fixes from hostile review:
- a corrective transfer whose deposit broadcast but whose bridge status
  never resolved is now TERMINAL for the scheduler slot (funds are likely
  still in flight, a retry double-sends); only pre-broadcast failures may
  consume another attempt. executeOnBridge keeps TxHash on error paths so
  callers can tell the two apart.
- ONE corrective-transfer budget (max 2) per scheduler slot, shared across
  all downgrade-ladder rungs (was per rung: up to 6 transfers per slot).
- package-level execution mutex: scheduler slots own the wallets end to
  end, the reaper TryLocks and skips its tick instead of queueing.
- broadcasts that failed or timed out after getting a TxHash book a flat
  FAILED_TX_FEE_ESTIMATE_USD against the daily cap instead of bleeding
  unaccounted gas; all spend goes through the persisted UTC-day tracker.
- gas top-up now honors MaxDailySpendUSD before broadcasting.

* bridge-monitor: SIMULATE_BALANCES only honored in dry-run mode

The reaper (and any other consumer) used to accept simulated balances even
in production with broadcast-capable keys, so phantom numbers could
green-light real transfers. SimulateBalances now takes the execution mode
and returns nil outside dry-run; startup logs a warning when the flag is
set in a broadcast-capable mode.

* tx executor: preserve hash on ambiguous send errors, split dry-run spend state

* executor: add missing chain label, first real single-test panicked post-fill (#1141)

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

* bridge-monitor: point Mobula calls at demo-api.mobula.io (#1142)

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

* tokenized-stock-peg: bench 076, onchain tokenized equities vs Nasdaq reference (staging gated)

* spec: missing store blob for a live bench throws instead of caching the miss (bnb-rpc awaiting incident) (#1144)

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

* tsp: parse the flat spark shape (close/timestamp arrays), log empty parses

* tsp: drop invalid rank_matrix_query, 1m reference candles, fee-band + USDG methodology notes

* rwa: new category, tokenized-stock-peg moves in, 11 equity logos + registry pages

* rwa: xstocks-peg (077) + usdy-nav-basis (078), issuer label on the tsp metric family

* rwa: complete product pages, hood/qqq/coin/orca/pyth logos + dual-issuer descriptions

* registry: googl links to google.com, abc.xyz 403s aggressively

* cardano: cache koios epoch params 1h (public tier was exhausted); stellar: protocol-minimum 5 reserves

* fix(store+bridge-monitor): fail-fast on missing blob + sub-50ms histogram buckets (#1152)

* spec: missing store blob for a live bench throws instead of caching the miss (bnb-rpc awaiting incident)

* bridge-monitor: sub-50ms buckets + nanosecond precision on Observe

Histogram Buckets grew from [50, 100, 200, 500, 1000, 2000, 5000, 10000]
to [10, 25, 50, 100, 200, 500, 1000, 2000, 5000, 10000]. Near Intents
on solver-cached corridors (HyperCore in particular) returns via a
1Click coordinator fast path in <50ms, and the previous scheme lumped
every one of those observations into (0, 50] so histogram_quantile
linearly interpolated to ~25ms regardless of true value. The p50 shown
on /benchmarks/bridge-quote-latency?chain=HyperCore was therefore a
bucket floor, not a measurement. Sub-50 buckets let the real bimodality
surface.

Also swap Observe(float64(quoteLatency.Milliseconds())) for
Observe(float64(quoteLatency.Nanoseconds()) / 1e6) across all 6
per-bridge observers (across, debridge, lifi, mobula, nearintents,
relay). int64 Milliseconds() truncates every sub-1ms round-trip to 0,
same bug class as the RPC harness fix in PR #1128. Not a hot path here
(network RTT dominates), but keeps precision consistent with the new
low buckets and cheap to fix while we are already touching each file.

Bench YAML methodology + FAQ updated to reflect the new bucket set.

---------

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

* fix: 3 machine-readable surface bugs (citable unit scaling, stat filters, draft asOf) (#1154)

* fix: 3 machine-readable surface bugs surfaced by GEO audit

1. /api/citable/{date} unit scaling.
   Snapshot route emitted `value: 627` for `aggregator-head-lag` while the
   live /api/citable emitted `value: 0.63` on the same measurement. The
   live route already wraps with valueInDeclaredUnit(raw, b.unit) but the
   snapshot route was left with the raw internal-ms value. Wrap the same
   way; sub-1 s benches now render in the declared `s` unit on both routes.

2. /api/stat/{slug} silently ignored ?chain= / ?region= / ?kind= / ?venue=.
   getBenchmark accepted a filters object but the route never parsed the
   URL. A citer asking /api/stat/rpc-capabilities?chain=ethereum got the
   cross-chain aggregate. Parse the four dimension params, pass through
   to the loader; unknown values fall back to unfiltered inside the
   loader. Also widen getBenchmark options type to include kind and
   venue — the underlying loadBenchmark already supports both.

3. Draft benches leaked a fake freshness signal.
   Draft placeholder set lastRunAt to new Date for type safety
   (Benchmark.lastRunAt is non-null), and both citable routes passed it
   straight through as asOf. LLM crawlers treating asOf as ground truth
   would think a draft bench was measured every minute. Null asOf in the
   JSON when status is draft; the internal type stays intact.

* fix: propagate draft-asOf null-out to /api/stat, MCP, JSON-LD dateModified

The GEO audit review found the initial fix in commit f6f84b8 only covered
the two /api/citable JSON surfaces, but the same "draft benches spoof
freshness" class of bug lives on three more machine-readable channels
that LLM crawlers also key on:

- /api/stat/{slug} asOf field.
- MCP tool responses (list_benchmarks, get_benchmark, resource template,
  and the plain-text "Last sample" line).
- JSON-LD dateModified on the bench page, per-chain sub-page, answer
  page and alternatives page — Google, Bing, Perplexity all consume
  dateModified as a freshness ranking signal.

Centralize the guard as citableAsOf(b) in @/lib/citation, rewire every
call site. Draft benches now uniformly omit or null the freshness
timestamp; live benches unchanged. On the visible /answers page,
the "Data as of..." line and its <time dateTime> anchor also hide for
drafts rather than displaying the wall-clock placeholder.

Follow-up to PR #1154 (commit f6f84b8) covering the surfaces the review
agent flagged as still spoofing.

---------

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

* feat: /api/compare/{a}/{b} + openapi.json coverage (Wave 1 P0/P1) (#1156)

* feat: /api/compare/{a}/{b} + openapi.json coverage

Two GEO surfaces flagged by the audit as high-value gaps.

## /api/compare/{a}/{b}

Head-to-head comparison as a single citable payload. LLMs asked "Mobula
vs Codex head lag" would otherwise need two /api/stat calls plus their
own delta reasoning; this endpoint returns one flat structure with per
benchmark p50 for each side, the winner on the metric's
higher-is-better convention, and the absolute delta.

The intersection logic mirrors src/lib/related-providers.ts and the
HTML /compare/{slug} page: shared benchmark = both providers appear in
it. Intentionally returns flat leader-p50 only (no chain/region
breakdowns, no cache-heavy fan out) so the agent tool call stays fast.

## openapi.json coverage

Added /api/citable/{date} (was missing despite being a real API
endpoint) and /api/compare/{a}/{b} to the OpenAPI 3.1 spec. Also
documented the dimension query params (chain/region/kind/venue) on
/api/stat/{slug} that shipped in the previous PR but were undocumented.
Added Compare schema; extended Stat schema with `filters` field.

/llms.txt and /llms-full.txt stay outside the OpenAPI surface — they
are text files not RPC endpoints, and OpenAPI consumers do not handle
plain-text specs well anyway.

## citableAsOf helper widened

Type signature relaxed from full Benchmark to
`Pick<Benchmark, "status" | "lastRunAt">` so the slim
ProviderAppearance.benchmark used by the new compare route can pass
through without a downcast. No behavior change on existing callers.

## Notes on items NOT shipped in this batch

- Cache-Control on /llms.txt / /api/citable / /api/llm-context: the
  audit report showed `cache-control: public` and inferred no CDN
  caching, but the source already sets
  `public, s-maxage=300, stale-while-revalidate=900`. Vercel strips the
  `s-maxage` directive from the client-visible header while honoring it
  at the edge; `x-vercel-cache: HIT` with `age: 256` confirms the CDN
  cache is active. Not a real bug.
- .bib / .ris file MIME endpoints: string versions already ship inside
  the /api/stat JSON via cite.bibtex / cite.apa. Marginal upside for
  Zotero-style one-click imports; downgraded to P2.
- /api/stat/{slug}/sparkline.svg: sparkline data already ships as a
  float array in /api/stat and the OG image at /api/og/{slug} covers
  the "embed a chart in an answer" case. P2.
- JSON-LD `Dataset.variableMeasured.value`: current schema emits bare
  strings; upgrading to PropertyValue objects would duplicate the
  measured value already carried by the StatisticalReport node's
  Observation.measuredValue. P2.

* openapi: add totalWins to Compare schema

---------

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

* feat: Wave 1 P2 batch — cite MIME, sparkline SVG, Dataset PropertyValue (#1158)

* feat: Wave 1 P2 batch — .bib/.ris/apa MIME, sparkline SVG, Dataset PropertyValue

Three GEO surface additions the audit downgraded to P2. Each one is
duplicative with an existing channel (cite.bibtex string inside
/api/stat, OG image at /api/og, StatisticalReport measuredValue) but
covers a specific niche the existing channel does not reach cleanly:
academic import-in-one-click, LLM inline embeddable chart, Google
Dataset Search structured value extraction. Ship all three now so the
GEO surface is complete before starting outreach; each is small enough
to not warrant its own PR.

## /api/cite/{slug}/{format}

Per-bench citation with the correct MIME type. Four formats:
  - bib -> application/x-bibtex (Zotero, LaTeX)
  - ris -> application/x-research-info-systems (EndNote, Mendeley)
  - apa -> text/plain (ready-to-paste APA string)
  - txt -> text/plain (ready-to-paste plain attribution)

Content is the same as the cite.* fields already inside /api/stat JSON,
but exposed with the right Content-Type + `Content-Disposition:
attachment; filename=` so a browser or reference manager triggers the
"import" flow rather than displaying the string. Added RIS to CiteBundle
in src/lib/citation.ts (TY GEN neutral fallback, AU / TI / PY / UR / Y2
/ ER fields, CRLF line endings per RFC convention).

## /api/sparkline/{slug}

Standalone SVG sparkline of the current leader's 24 h series. Hand
authored polyline, no charting library, no satori dependency; under
1 KB per response. Query params: `w` (40..1200, default 240), `h`
(20..400, default 60), `theme=light|dark`. Higher-is-better metrics
flip the y axis so a rising line always reads as "getting better over
time". Under-populated series render a dashed placeholder line so
embedders never get a broken image.

Cases this reaches that /api/og and the raw JSON sparkline array do
not: img/svg inline embeds in Perplexity Pages, ChatGPT answers, dev.to
posts, Notion pages. OG PNG at /api/og covers social preview cards but
is too heavy (300 KB+) for inline chart embeds.

## Dataset.variableMeasured -> PropertyValue

Bare-string array in Dataset JSON-LD upgraded to include structured
PropertyValue objects when the current leader has real numeric
aggregates. Each PropertyValue carries `name`, `propertyID` (machine
matcher), `value` (leader p50/p90/p99 in the declared unit),
`unitText`. sample_size stays a bare string — it labels an axis, not a
measurement. Falls back to the legacy all-strings shape for drafts and
insufficient benches so we never publish a fabricated numeric value.

New helper: buildBenchVariableMeasured() in src/lib/dataset-jsonld.ts.
BenchDatasetInput.variableMeasured now accepts
Array<string | VariableMeasuredValue>. `keywords` extraction pulls the
`.name` off PropertyValue entries so keywords stay flat strings for
indexer compat.

## openapi.json

Both new endpoints registered under paths, so agent frameworks
discovering the API through OpenAPI can call them.

* sparkline: XML-escape bench title + provider name in SVG

---------

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

* bridge-monitor: Mobula EIP-712 signature-confirm flow (fixes stuck deposits) (#1160)

* bridge-monitor: implement Mobula EIP-712 signature-confirm flow (fix stuck deposits)

* mobula sig: inject EIP712Domain type definition, API omits it

---------

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

* robinhood chain vague 1: weekend drift (079) + arb latency (080) + subsidy tracker (081)

* subsidy is a chain KPI, not a bench: kill standalone spec, add 3 cards to /chains/robinhood strip

* pm-api-latency: add Predexon (bench 6th provider), fresh key deployed on the 3 Railway probe regions

* usdy: 3-retry on Orca RPC to fix 40% success rate (#1168)

publicnode.com burst-limits the VPS on getAccountInfo, dropping
~60% of samples. Two retries with 500ms backoff bring hit rate to
>95% without switching endpoints. Also add commitment=confirmed
to reduce slot-miss null returns.

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

* bench vague 2: ship 081-085 (ws latency, oracle freshness, rpc reliability, indexer latency, block builders) (#1170)

* add bench 081 ws-head-latency: WS block-push lag harness + spec

* add bench 084 indexer-latency: HyperSync vs The Graph vs Alchemy time-to-queryable

* idx-latency: only classify error when every poll errored, else timeout

* bump bench-set cache keys for 084 (lockstep rule)

* add bench 082 oracle-freshness: staleness metrics in oracle-deviation harness (multi-chain chainlink, pyth publish_time fix, redstone), bump bench-set cache keys

* oracle-freshness: count unsupported mainnet feeds in freshness error counter

* add bench 085 evm-block-builders: ETH builder share via extraData + relay bidtraces + Arb/Base soft-conf lag, bump bench-set cache keys

* ebb: don't count historical relay payloads on first poll, retry arb calibration on zero support, reset WS backoff after stable session

* add bench 083 rpc-reliability: consensus lag, hash quorum, fixed-vector integrity in rpc-capabilities harness, bump bench-set cache keys

* bench 083: clamp_min rank_matrix_query so zero-incident providers keep cell ranks

* gate benches 081-085 behind staging until 48h clean data

* drop shipped RWA slugs from prod gate (live on main since #1162/#1167)

---------

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

* hyperliquid frontend page: add ProviderLogo in the hero next to the name

* hl frontends: add slash, topdog, markets-mobile logos + registry entries; dark-mode chip for slash + markets-mobile

* idx-latency: swap Alchemy for Mobula in the cohort (paid-plan key, bench races $0 pipelines only) (#1176)

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

* fix(bench): sample-size PromQL + leader reliability floor (#1175)

* fix(bench): sample-size PromQL + leader reliability floor

Two bench-correctness fixes surfaced by an audit of the oracle and
gas surfaces we plan to cite in outreach.

Bench 025 (oracle-deviation):
- sample_size was 'count(count by (source) (ocb_oracle_price))'
  which returns the count of distinct sources (3 or 4), not the
  count of samples over the window. The citable API therefore
  reported sampleSize=3-4 on every pair, at odds with the spec's
  'polled every 30s' claim. Rewrites the query to
  'sum(count_over_time(ocb_oracle_price[24h]))' across all 10
  pair entries so the number reflects actual poll volume.

Leader ranking:
- citationCandidates() now excludes providers with a measured
  success rate below 50 percent from the leader pool. Before this
  fix, gas-estimation showed Owlracle as #1 with successRate=6.48
  and a p50 gap of 0.001 gwei: technically accurate on the rare
  successful poll but useless as a leader claim. Falls back to the
  full live pool when every provider is under the floor so a
  totally degraded bench still surfaces a best-of-bad-options
  leader rather than vanishing. Freshness benches without a
  success query default to 100 percent in the loader so the guard
  is inert there.

Adds src/lib/citation.test.ts with 5 tests covering the Owlracle
scenario, higher-is-better ranking, and the degraded-pool
fallback.

* fix(bench): propagate reliability floor to rankings + template + hub

Follow-up on the previous commit. Review flagged that filtering the
leader without filtering the ranking arrays leaves self-contradictory
JSON: /api/stat can report leader=Etherscan while rankings[0]=Owlracle
on the same document.

Exports citationCandidates and adds rankedCandidates helper that
returns the same eligible pool sorted by the bench's higher_is_better
direction. leader() and fieldValue() now share it.

Wires the shared helper into every surface that emits a rankings-like
list or a best-of claim:

- src/app/api/stat/[slug]: rankings array now sorted from
  rankedCandidates so rankings[0].slug always equals leader.slug on
  the same JSON.
- src/app/api/llm-context: the Rankings numbered list under each
  bench matches the Headline sentence above it.
- src/app/api/mcp/[transport]: get_benchmark tool response and the
  openchainbench://benchmark/{slug} resource both share the pool.
- src/lib/bench-template: {{best_name}} / {{best_p50}} / {{worst_name}}
  tokens in bench copy resolve against the same pool so a bench body
  never crowns a provider its own headline excludes. Per-slug tokens
  {{p50:some-slug}} still resolve against the raw live pool so unknown
  tokens still fall through.
- src/lib/providers: rankProviders (drives /products hub wins count
  and per-chain leadership chips) now runs on the reliable pool.

Adds a consistency test locking that rankedCandidates[0].slug always
matches leader().slug on the same bench. Total: 6 tests in
citation.test.ts, 95/95 pass across src/.

Uses (r.successRate ?? 100) for the numeric guard to future-proof
against ProviderResult shapes that skip the field. Load path still
defaults to 100 (materialize/load.ts:748) so this is inert today, but
the extra ?? removes the fragility.

/api/compare/[a]/[b] is intentionally left unfiltered: the user
picks the two providers explicitly, so the head-to-head is a
requested comparison rather than a leader claim. Applying the floor
there would drop rows the user explicitly asked to see.

* fix(hub-card): honor citation leader in the hub grid headline

Follow-up review flagged that benchmark-card.tsx re-sorted b.results
locally and picked results[0], so a bench where citation demotes the
raw-p50 winner (e.g. Owlracle on gas-estimation) would show the
demoted provider on the hub grid while the bench page names the
correct leader. Two documents contradicting each other in one click.

Adds leaderSlug: string | null to BenchmarkCardData and populates it
at the projection boundary via leader(b), which already applies the
reliability + insufficient-sample filter. The card picks the leader
by matching that slug in the projected results, and falls back to the
raw best when the projection returned no leader (draft, insufficient,
or all providers filtered out) so a card still renders a headline
value in every state.

Kept the local sort so the fallback path and any downstream chip
using sorted[0].name keeps working.

Test suite unchanged: 95/95 pass across src/. No test needed since
this is a projection-boundary wire-up.

---------

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

* fix(registry): remove duplicate topdog entry (#1177)

Commit 349b786 (hl frontends: add slash, topdog, markets-mobile logos +
registry entries) added a 'topdog' entry at line 313 without noticing a
pre-existing 'topdog' entry at line 1061, breaking staging build with a
duplicate object literal type error.

Keeps the newer entry (Telegram bot at t.me/topdog_trade_bot, HL
frontend context) since it was the one intentionally added for the
Hyperliquid frontends leaderboard chip work. Drops the older Discord
signal bot entry (topdog.gg): the bench spec at
benchmarks/hyperliquid-frontends.yml has slug: topdog with name TopDog
and matches the newer, HL-native product.

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

* vague2 QA: unit sec for true-seconds benches (fixes 0.0s heros), soften 080 findings, drop dead 085 builder rows, 083 expected_n 40 (#1178)

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

* add bench 086 polkadot-rpc: first Substrate chain in the RPC cluster (#1179)

Extends rpc-capabilities to Polkadot, the first non-EVM, non-Solana
chain in the cluster. Substrate JSON-RPC via chain_getHeader (returns
hex-encoded block number matching EVM's eth_getBlockByNumber shape),
staleness by relay-block gap (40 blocks, ~4 min at Polkadot's ~6 s
cadence).

Harness changes:
- Chain.Kind gains "polkadot" alongside "solana" and default (EVM).
- New callSubstrateHeader: chain_getHeader POST, parses hex block
  number, returns parentHash as the observed hash. Same latency
  measurement + result classification as the EVM path.
- probeOne switches on Kind (was if solana / else). polkadot uses
  polkadotStaleBlockGap = 40. Consensus observation is skipped
  because chain_getHeader returns parentHash (N-1), not the current
  block hash (deriving current hash needs Blake2 over the SCALE-
  encoded header, out of scope).
- Archive loop is skipped for polkadot the same way as solana:
  Polkadot state accesses via state_getStorage keyed by Blake2 storage
  keys, no chain-agnostic depth analog for eth_getBalance-by-depth.

Providers at launch (3, live-verified on 2026-07-15 with four
consecutive chain_getHeader probes each):
- polkadot-official (Parity, rpc.polkadot.io)
- onfinality (public gateway, Web3 Foundation grantee)
- publicnode (Allnodes, extends its multi-chain footprint to
  Substrate keyless)

Audited and excluded from the launch cohort:
- 1RPC: filters chain_getHeader as "Not Allowed" on the privacy
  relay. Revisit if they expose a Substrate-friendly method set.
- Dwellir: 503 Service Unavailable during the audit sweep, revisit
  once the gateway stabilises.
- Ankr: paid Polkadot tier only.
- Chainstack: key-gated on Substrate.
- RadiumBlock: rejects without referral header.
- Grove / Thirdweb: invalid-chain response.

Spec (benchmarks/polkadot-rpc.yml, bench № 086):
- Same schema as moonbeam-rpc / avalanche-rpc etc. so the parent
  rpc-capabilities benchmark, chain cluster page and hub card
  render it identically.
- Methodology documents the Substrate specifics: probe method
  choice, staleness scaling, why no archive-depth or hash-quorum
  columns.
- SEO/finding tokens use the standard {{best_name}} / {{best_p50}}
  / {{p50:slug}} template surface, consistent with other -rpc
  benches.
- Provider tag copy explains OnFinality and Dwellir's Web3
  Foundation grantee status so the leaderboard reads correctly
  for someone who knows the Polkadot infra landscape.

Local checks:
- go build ./... clean.
- go test ./... clean.
- bun test src/ - 95/95 pass across 10 files, including the
  citation reliability floor and consistency tests added in #1175.

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

* product pages: 9 block-builder registry entries + verified logos, oracle logos (pyth/redstone) (#1181)

* products: registry entries + logos for bench 085 block builders

* oracle logos: redstone official logo + pyth/redstone manifest + registry entries

---------

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

* 085: add bobthebuilder provider row (real share, was metrics-only) (#1182)

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

* polkadot-rpc bench 086 + /chains/polkadot + Parity product (#1184)

* add bench 086 polkadot-rpc: first Substrate chain in the RPC cluster

Extends rpc-capabilities to Polkadot, the first non-EVM, non-Solana
chain in the cluster. Substrate JSON-RPC via chain_getHeader (returns
hex-encoded block number matching EVM's eth_getBlockByNumber shape),
staleness by relay-block gap (40 blocks, ~4 min at Polkadot's ~6 s
cadence).

Harness changes:
- Chain.Kind gains "polkadot" alongside "solana" and default (EVM).
- New callSubstrateHeader: chain_getHeader POST, parses hex block
  number, returns parentHash as the observed hash. Same latency
  measurement + result classification as the EVM path.
- probeOne switches on Kind (was if solana / else). polkadot uses
  polkadotStaleBlockGap = 40. Consensus observation is skipped
  because chain_getHeader returns parentHash (N-1), not the current
  block hash (deriving current hash needs Blake2 over the SCALE-
  encoded header, out of scope).
- Archive loop is skipped for polkadot the same way as solana:
  Polkadot state accesses via state_getStorage keyed by Blake2 storage
  keys, no chain-agnostic depth analog for eth_getBalance-by-depth.

Providers at launch (3, live-verified on 2026-07-15 with four
consecutive chain_getHeader probes each):
- polkadot-official (Parity, rpc.polkadot.io)
- onfinality (public gateway, Web3 Foundation grantee)
- publicnode (Allnodes, extends its multi-chain footprint to
  Substrate keyless)

Audited and excluded from the launch cohort:
- 1RPC: filters chain_getHeader as "Not Allowed" on the privacy
  relay. Revisit if they expose a Substrate-friendly method set.
- Dwellir: 503 Service Unavailable during the audit sweep, revisit
  once the gateway stabilises.
- Ankr: paid Polkadot tier only.
- Chainstack: key-gated on Substrate.
- RadiumBlock: rejects without referral header.
- Grove / Thirdweb: invalid-chain response.

Spec (benchmarks/polkadot-rpc.yml, bench № 086):
- Same schema as moonbeam-rpc / avalanche-rpc etc. so the parent
  rpc-capabilities benchmark, chain cluster page and hub card
  render it identically.
- Methodology documents the Substrate specifics: probe method
  choice, staleness scaling, why no archive-depth or hash-quorum
  columns.
- SEO/finding tokens use the standard {{best_name}} / {{best_p50}}
  / {{p50:slug}} template surface, consistent with other -rpc
  benches.
- Provider tag copy explains OnFinality and Dwellir's Web3
  Foundation grantee status so the leaderboard reads correctly
  for someone who knows the Polkadot infra landscape.

Local checks:
- go build ./... clean.
- go test ./... clean.
- bun test src/ - 95/95 pass across 10 files, including the
  citation reliability floor and consistency tests added in #1175.

* polkadot page + parity/onfinality/dwellir products

Wires the /chains/polkadot hub page, provider profiles, and logo
assets that surface once bench 086 polkadot-rpc starts feeding Prom.

- chains.ts: add Polkadot to CHAINS registry (L1, nativeSymbol DOT).
  Auto-populates /chains/polkadot once benches referencing chain="polkadot"
  land. Adaptive KPI strip fills in DefiLlama TVL, DEX volume and stables
  when the chain-kpis harness starts sourcing Polkadot alongside the
  other L1s.
- provider-registry.ts: three new entries for the Polkadot cohort.
    * parity: brand entry for Parity Technologies (Polkadot / Substrate
      implementation shop, operator of rpc.polkadot.io).
    * polkadot-official: endpoint-level entry describing the reference
      rpc.polkadot.io keyless endpoint; providers.ts aliases the slug
      to parity so the /products page collapses under the operator's
      brand.
    * dwellir: brand entry for Dwellir (Web3 Foundation grantee, keyless
      Substrate infra) — excluded from the 086 launch cohort due to a
      503 during the audit sweep but kept in the registry for the
      revisit and for other places their brand might appear.
- providers.ts: add polkadot-official → parity alias and canonical
  brand casings for polkadot / parity / onfinality / dwellir so the
  /products hub, badges, and OG images render the correct name
  without the title-case fallback butchering them.
- logo-manifest.ts: polkadot.png (chain hero), parity.png (product page
  logo), plus polkadot-official → parity slug alias so the endpoint
  chip on the bench page picks up Parity's mark automatically.

95/95 pass on bun test src/. Local build clean.

---------

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

* fix: dedup onfinality key in logo-manifest (already declared line 49) (#1186)

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

* store: last-known-good snapshot fallback (no more awaiting after first publish) (#1188)

publishSnapshot writes an lkg copy without TTL on every success;
readMaterialized falls back to it when the pointer/blob pair misses
(worker outage past blob TTL, DEL race, transient). dataAsOf keeps the
freshness badge honest about age. LKG write is best-effort and never
fails the publish.

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

* chain-kpis: add polkadot to registry (DOT native, stables via Asset Hub) (#1191)

Ships live KPIs on /chains/polkadot. DOT price and mcap flow through
Mobula. Stables card renders via DefiLlama Asset Hub aggregate.
TVL and DEX gauges stay unpublished (relay chain has zero DeFi:
parachain TVL lives under Acala, Moonbeam, Hydration).

Also guards defillama TVL publish to skip zero values so chains that
DefiLlama tracks by name but with an all zero series (Polkadot today)
hide the TVL card instead of rendering $0.

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

* live-feed status: Feed down pill + banner on aggregator-head-lag (#1193)

Short-window changes() probe per provider surfaces silent live feeds
that the 24h quantile aggregate hides (Codex WS blackout 08:23-09:04
UTC today moved the p50 by nothing but the whole feed was mute for
40min). Bench-level probe_ok gate suppresses badges when our harness
side is broken so a Railway crash cannot fake-flag every provider.

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

* series gaps: null the chart line when a feed goes silent (unless changes==0) (#1195)

Companion to feed-down banner: makes the 40min Codex blackout from
this morning (08:23-09:04 UTC) visible on the 24h chart instead of a
flat line covering the outage. Per-series match preserves the
aggregate line as long as one chain still receives events.

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

* add bench 087 ws-head-latency-base + 088 ws-head-latency-solana (#1196)

Per-chain siblings of bench 081 for the WebSocket head-lag cluster.
Same harness (harnesses/ws-head-latency), same race scoring, one
spec per chain to avoid mixing block cadences (Ethereum 12s vs Base
2s vs Solana 400ms) in a single leaderboard.

Base cohort: PublicNode + dRPC keyless endpoints. Tenderly does
not expose a keyless Base WebSocket path.

Solana cohort: PublicNode + Solana Foundation. slotSubscribe
replaces newHeads on the harness side; metric names are shared so
the queries are structurally identical to the EVM specs.

Data already flows via the Paris VPS deployment (chain label carries
base and solana). No harness change needed for this ship. Multi-region
is a follow-up on the parent bench.

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

* ws-head-latency: multi-region (harness region label + spec region tabs) (#1198)

* ws-head-latency: region label via ConstLabels + Railway config

Adds a static region ConstLabel to every ws-head-latency metric,
bound at process startup from OCB_REGION (preferred) or
RAILWAY_REPLICA_REGION (Railway auto-set), with eu-west fallback
matching the historical single-vantage Paris VPS deployment.

No observation site needs to change: the label is baked into every
counter, gauge and histogram at registration, so all existing
WithLabelValues(provider, chain) calls stay identical.

Adds railway.toml so Railway can build the harness from Dockerfile
with a /health check, matching the rpc-capabilities service layout.

Follow-up: create the Railway ws-head-latency service with 3
replicas (us-east, eu-west, sgp) and OCB_REGION set on each, then
decommission or leave the Paris VPS container as a 4th vantage.

* ws-head-latency specs: add region dimension tabs (081 + 087 + 088)

Adds `dimensions.region` block + `rank_matrix_query` on provider,
region + per-provider `regions:` sub-arrays with region-scoped
queries on the 3 ws-head-latency specs, following the polkadot-rpc
pattern.

Depends on the harness region label change (previous commit): every
metric now carries a region ConstLabel so the region-scoped
histogram queries return values as soon as multi-region replicas
publish. Until Railway 3-replica deploy lands, only region=eu-west
has data and the other tabs render empty; the All regions tab keeps
working because the aggregate query is unscoped by region.

---------

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

* ws-head-latency: align region env var with rpc-capabilities (REGION not OCB_REGION) (#1200) (#1201)

Every other multi-region harness on Railway (rpc-capabilities, aggregator-head-lag, evm-swap-quoting, pm-rate-limits, etc.) reads REGION from env. Making ws-head-latency read REGION too keeps the Railway service config uniform across the whole harness fleet.

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

* chart: red band overlay for null-bucket downtime windows (#1203)

Companion to #1195 (series null gaps). The gaps themselves render as
line breaks but a break can read as 'the chart missed a sample'. A
soft red column behind the break spans the exact outage window and
makes the reader understand it was a real feed-side silence. Per
line + per contiguous gap range, extended by one step so 1-bucket
outages stay visible instead of collapsing to a zero-width sliver.
Hidden for excluded (legend-toggled) providers.

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

* rename ws-head-latency -> ws-head-latency-ethereum for sibling slug parity (#1204)

081 becomes ws-head-latency-ethereum so the 3 per-chain benches in the head-lag cluster share the same shape: ws-head-latency-ethereum / -base / -solana. The old ws-head-latency slug is dropped (was staging-gated via REMOVED_BENCH_SLUGS, never live on prod, no external SEO to preserve).

Ungated on prod at the same time so the new slug ships alongside the base + solana siblings that are already live. Sibling text references updated to the new URL. Cache keys bench-unfiltered and all-benchmarks bumped so cached v29/v32 entries do not keep the old slug alive.

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

* network-coverage: drop coinstats (free tier credits exhausted) (#1206)

CoinStats /wallet/blockchains returns HTTP 406 "Credits limit reached" on every fetch: the free tier quota is spent below our 6h cadence and the provider never publishes a network count. Dropping until a paid key is provisioned.

Removes the coinstats.go adapter, the CoinStatsAPIKey config field, the coinstats entry in main.go, and the coinstats provider row + prose mentions in the spec. Cohort drops 6 -> 5 providers (GeckoTerminal, Codex, Mobula, CoinPaprika, Dune via Sim API).

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

* Revert "network-coverage: drop coinstats (free tier credits exhausted) (#1206)" (#1208)

This reverts commit d295f7168da981078164119e508ca9546ed69fa2.

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

* downtime bands: provider-colored tint + name/silent label + edge markers (#1210)

Feedback on #1203: the neutral red band read as 'something happened
here' without telling the reader WHO. Tint each band in the
provider's own color, add dashed edges at the outage boundaries, and
label 'PROVIDER · SILENT' above the band when it fits (with a caret
that survives on narrow single-bucket bands). Works uniformly for
Mobula/Codex/Gecko or any future provider with a live_activity
query.

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

* downtime bands: pill labels + vertical stacking on overlap (#1211)

Screenshot review: the small text-only label above the band was still
too subtle to read as 'this provider is down', and 3 simultaneous
provider outages produced overlapping unreadable labels stacked on
the same y. Replace with high-contrast rounded pills (white text on
provider color) that stack vertically when their X-ranges overlap.
Renders as '● NAME DOWN' in bold small caps so a reader gets the
event and the provider identity at a glance.

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

* tsp-arb-latency: lower trigger 50 -> 25 bps + credit sub-poll closures (#1212)

Bench 080 published only 7 samples across 3 of 11 assets in 24h at
50 bps trigger. Two contributing causes fixed together:

1) Trigger from 50 -> 25 bps. 50 bps in 90s is a headline move on
   Nasdaq (earnings, macro prints), not a steady-state event. 25 bps
   is above normal intra-minute noise on liquid stocks and captures
   real arb-worthy moves without collapsing the semantic.

2) Sub-poll convergence credited. When the reference moves past the
   trigger AND the pool is already within band on the same tick, the
   arb closed inside one poll interval. Previously dropped silently;
   now observed at pollInterval (60s) so the fast-arb tail on liquid
   pools stops disappearing from the histogram.

Convergence threshold stays at 20 bps: the "how close is the pool to
fair" question is unchanged, only trigger sensitivity moves. Spec
text (subtitle, methodology, FAQ) updated to 25 bps throughout.

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

* downtime pill: drop the leading dot indicator, center the label (#1213)

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

* downtime pill: 'DATA MISSING' wording + skip leading gaps (#1215)

Two fixes based on 30D view review:

- Leading gaps (Prom retention shorter than the visible window,
  harness started mid-range, provider added recently) rendered as
  a chart-wide band claiming every provider was down for the
  missing period. Skip runs that started before the first observed
  sample.

- Rename 'DOWN' to 'DATA MISSING'. A silent series can be the
  provider going down, our harness losing its WebSocket, or a Prom
  scrape failure — the pill stays neutral instead of blaming the
  provider.

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

* drop bench 084 indexer-latency (harness pruned, spec deleted) (#1218)

HyperSync and The Graph need paid keys for the sustained cadence the
bench expects; Mobula alone left the leaderboard single-provider and
the spec never made it out of REMOVED_BENCH_SLUGS gating. Simpler to
drop the whole cluster than to buy 2 SaaS subscriptions to prop up
a bench nobody has cited yet.

Removed:
  * benchmarks/indexer-latency.yml
  * harnesses/indexer-latency/ (whole directory)

Kept in REMOVED_BENCH_SLUGS as 410-gone so any indexed URL returns
Gone rather than 404. Cache keys bumped so cached v30/v33 entries
that still enumerate the slug age out on next revalidate.

VPS follow-up (separate ops): stop ocb-indexer-latency container,
remove from docker-compose.yml + prometheus.yml scrape config.

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

* video/render: strict Zod body schema before upstream forward (#1216)

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

* mcp: allowlist perp_funding_*, perp_venue_*, perp_execution_* prefixes (#1220)

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

* aggregator-head-lag: drop 'unless changes==0' from series queries (#1221)

Bug: the guard was designed for modern 15s-cadence data, where
changes([15m]) works cleanly. On backfilled historical data compacted
to ~1 sample/hour, changes([15m]) returns 0 even though the gauge is
healthy — the 30D view lost the first 8 days of data to false-drops
(all three providers marked DATA MISSING for a whole week).

Real-time silence is still surfaced by the live_activity scalar
(pill + banner + downtime band), which runs against fresh scrape
cadence and stays accurate. The chart series now shows every point
Prom has, flat during old outages instead of gapped.

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

* seo: 200 placeholders + sitemap threshold buffer (#1222)

- badge/[slug]/[provider]: serve pending SVG (200) instead of 404 when a provider is missing ranking data
- hyperliquid/[slug]: render lightweight placeholder page (200) instead of 307 to /hyperliquid when history blob is missing the builder
- compare adHocPairs: bump inclusion threshold to 3 (brand) / 4 (other) so a single bench flap can't cross both the sitemap gate and the page noindex gate

Fixes 3 recurring Ahrefs issues (broken badge images, noindex-in-sitemap on compare, 3xx-in-sitemap on hyperliquid) all rooted in the sitemap/page ISR race.

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

* aggregator-head-lag: restore outage detection with backfill-safe guard (#1224)

Follow-up to #1221. Dropping the guard entirely restored the 30D
range but silently hid today's Codex morning blackout on the 24H
view (frozen gauge value = flat line, indistinguishable from a
healthy quiet period).

Bring the guard back, gated on count_over_time > 5 samples in the
window. Modern data at 15s cadence carries ~60 samples per 15 min
so the gate fires the moment a WS goes silent. Backfilled data
compacted to ~1 sample/hour has count <= 2 in a 15m window so the
gate stays open and every point Prom kept is served — no more fake
wall-to-wall gap at the start of the 30D range.

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

* ws-head-latency: sub-ms histogram floor + keyed slots for base/solana (#1225)

Fix the leader-collapses-to-2.5ms artifact: histogram_quantile on a
[0,5ms] first bucket interpolates every winner's p50 to ~2.5ms
(chain-independent). Replace with a 0.5ms floor climbing to 10s so
sub-ms wins actually resolve on Solana (~400ms slots) and Base (2s).

Add keyed provider slots for Base (Alchemy/Infura/Chainstack/QuickNode)
and Solana (Alchemy/Chainstack/Helius/QuickNode) so the cohort can
extend beyond keyless once contributor keys are wired. Keyless tier
is saturated on all three chains (audit failed: Ankr, Blast, LlamaRPC,
BlockPI, 1RPC, NodeReal, Omniatech, base.org, extrnode, chainstack-
public — none accepted a keyless WSS handshake as of 2026-07).

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

* ship: ungate bench 085 evm-block-builders on prod (#1227)

Titan leads with sample=6973 in 24h, data healthy since the harness
ship. Removing from REMOVED_BENCH_SLUGS so the slug renders on prod
next revalidate. Cache keys bumped to bench-unfiltered-v32 +
all-benchmarks-v35 so cached entries do not keep dropping the slug
via the old prod gate.

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

* loader: propagate 24h series null-runs to 7d/30d (#1229)

Fine-grained outages (24h step = 20 min buckets) rarely align with
coarser evaluation timestamps (7d step = 2h, 30d step = 12h) so the
Prom query returns a healthy average and the reader sees the pill on
24H but nothing on 7D/30D. Post-process: for every contiguous null
run detected on s24, translate the time-ago range to bucket indices
on s7 and s30 and null every coarse bucket the run straddles.

Contiguity preserved so a single short outage doesn't split into two
adjacent pills after propagation. Same treatment applied to per-region
series.

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

* propagateNullsToCoarser: use absolute time-ago instead of window fraction (#1230)

Bug: the previous math scaled by the fine window fraction, which
projected a 7h-ago outage down to 40h-ago on the 7d grid (the pill
landed at -1.7d instead of -6h). Both grids share 'now' as their
right anchor and only the step sizes differ — take the fine idx's
absolute time-ago and divide by the coarse step to find the
covering bucket.

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

* tsp-arb-latency: drop 5 illiquid tickers (TSLA GOOGL AMZN MSFT SPY) (#1231)

Their Uniswap v4 pools on Robinhood Chain have no arb activity: pool spot stays frozen at the last swap value tick after tick while the Yahoo reference moves normally. Events open on ref triggers but roll over as still_open with zero latency samples. Cohort restricted to the 6 tickers with observable arb activity: NVDA AAPL PLTR META AMD MU.

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

* perp-fees harness: fix Paradex depth-skip, gains SOL slot, dYdX walk order, GMX Subsquid error surfacing (#1233)

- Paradex: cap fill ratio at 90% of visible book (depth=100). Tiers that eat through the tail now return book_too_thin and skip publishing instead of inflated 150-500 bps ghost values.
- gains: only cache USD-quoted pairs during pair discovery, widen scan 60->200. Root cause was a SOL/BTC pair overwriting the SOL/USD cache entry, publishing the wrong feeIndex and uniform 4.333 bps across ETH/BTC/SOL.
- dydx: sort v4-indexer level responses (asks ascending, bids descending) before walking the book. Insertion-order walk produced 1.7-2x inflated headline at 100k/1M tiers.
- gmx: prefix Subsquid errors, surface GraphQL errors[], fall back to unsuffixed positionFeeFactor if the impact-branch field is absent, and error out on zero factor. Prevents the silent-6bps-constant fallback path.

Verified: go build + go vet clean.

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

* downtime bands: opt-in via live_activity, skip on other benches (#1235)

Screenshot review: rpc-reliability rendered a 'LAVANODIES DATA MISSING'
pill even though its spec never declared live_activity — natural
nulls in its series24h (sparse scrapes, backfill edges) triggered
the DowntimeBands render. Gate the render on
benchmark.results.some(r => r.liveStatus != null), which is only set
when the spec declared live_activity. Same gate on
propagateNullsToCoarser so other benches' 7d/30d series stay
untouched.

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

* batch 2: MEV chips + rpc copy + Aster/edgeX + Cardano/Sui/Cosmos/Avax (#1236)

* site: provider_notes chip on ranking rows + rpc-capabilities copy fixes

- validator-yield now surfaces a per-provider chip (MEV inc./MEV excl./no MEV) beside each row so scan-readers can't accidentally compare Solana MEV-inclusive APR against Ethereum consensus-only APR without seeing the caveat
- New optional spec field provider_notes: {slug: string} plumbed end-to-end (Zod schema, TS type, materialize loader, spec overlay, ledger-table render)
- Chip renders only when the note is set, no visual change to any other bench
- rpc-capabilities: '10 EVM chains' copy corrected to 26 chains (24 EVM + Solana + Polkadot) across title/subtitle/seo_intro/abstract/methodology + FAQ
- rpc-capabilities disclaimer expanded to call out that the 'All chains' aggregate rewards single-chain foundation endpoints and to document Cloudflare-eth exclusion (returns -32603 on POST)

* perp-fees harness: add Aster + edgeX venues

- Aster (BSC): Binance-style depth walk against fapi.asterdex.com, hardcoded 5 bps taker (no public fee schedule API)
- edgeX (offchain CLOB): getDepth REST + hardcoded 3.8 bps taker per gitbook docs. Asset→contractId map declared inline (verify on first prod run; wrong ID surfaces as an orderbook fetch error, not a silent 0)
- Both venues probed for ETH/BTC/SOL at all 3 notional tiers ($1k/$100k/$1M) via applyBookTiers
- Closes the biggest coverage gap flagged by the perp-fees GEO audit (Aster is #2 perp DEX by 30d volume)

* validator-yield harness: add Cardano, Sui, Cosmos Hub, Avalanche

Closes the 'Highest staking yield' coverage gap: the bench previously ranked only Solana, Ethereum and Hyperliquid but AI answers to that question almost always include Cardano ($14B staked) and other top chains.

- Cardano (Koios API): top-50 pools by stake, net = ρ×(1-τ)×(1-margin). Simplified vs full a0/k/saturation formula but lands within ~50-100 bps of pooltool.io.
- Sui (public RPC suix_getValidatorsApy + suix_getLatestSuiSystemState): all active validators, apy is already net of commission per Sui docs.
- Cosmos Hub (LCD REST): top-200 bonded validators, net = inflation×(1-communityTax)/bondedRatio×(1-commission).
- Avalanche (P-Chain platform.getCurrentValidators): top-100 by stake, base 8.5% APR × uptime × (1 - delegationFee/100).

Reuses existing ocb_validator_* gauges from metrics.go (no new metrics, no go.mod changes). Env-overridable endpoints declared inline in each fetcher matching solana.go style. Shortcuts (uptime=100% for Cardano/Sui/Cosmos, hardcoded community_tax=2%, base 8.5% for Avalanche) documented in code.

---------

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

* rpc-capabilities: add 4 keyless providers (ThirdWeb Blast Gateway.fm bloXroute) (#1238)

Broadens the keyless cohort for benches 010 (rpc-capabilities), 083
(rpc-reliability) and the per-chain RPC benches. All 4 providers
verified live returning valid eth_getBlockByNumber on their
respective chains before inclusion.

New (provider, chain) cells:
  ThirdWeb    8 chains (ETH POL ARB OP BASE BSC AVAX LINEA)
  Blast API   4 chains (ETH ARB BASE BSC) — POL OP AVAX LINEA are 403 paid tier
  bloXroute   3 chains (ETH BASE BSC)
  Gateway.fm  1 chain  (ETH)

Total = 16 new cells. Metrics auto-flow into bench 083 (rank_matrix
groups by provider+chain) and the per-chain leaderboards after the
Railway rebuild picks up main.

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

* ws-head-latency: add Onfinality Ethereum to keyless cohort (#1241)

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

* drop oracle-freshness (082) spec + sync REMOVED with main state (#1245)

Oracle-freshness deleted from the site: apples vs oranges (Chainlink push vs Pyth/RedStone pull measure different things), splitting into pull/push sub-benches would leave each with 1-2 providers, redundant with bench 025 oracle-deviation which measures the same feeds via CEX deviation with a more actionable signal.

Slug kept in REMOVED_BENCH_SLUGS as 410 Gone. Harness code in oracle-deviation retained (metrics still emit but no site reader).

Also syncs dev's REMOVED_BENCH_SLUGS with main: removes tokenized-stock-arb-latency + rpc-reliability (shipped to prod earlier today) so dev matches main state.

Cache keys bumped bench-unfiltered-v34 + all-benchmarks-v37.

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

* SEO/E-E-A-T pack: /team page, Person schema, 23 positioning, 8 expert_take (#1249)

Adds a broad SEO and GEO upgrade landing three surfaces at once:

- /team page with Florent Tapponnier as Maintainer, sameAs GitHub +
  LinkedIn, Person JSON-LD wired into Organization.founder and into
  every bench StatisticalReport.contributor + TechArticle.author +
  reviewedBy so YMYL crypto queries see a named human accountable.
- 23 positioning fields on alternatives specs (200 words each,
  factual, sourced from official docs, fact-checked via WebFetch).
  Cuts the shared-template surface from ~65 percent to ~30 percent
  across pages that reference the same benchmark.
- 8 expert_take fields on answers specs (150-200 words each,
  structural commentary that stays valid regardless of who currently
  leads the leaderboard) with QAPage schema so the acceptedAnswer
  (Org, auto) and suggestedAnswer (Person, expert_take) both reach
  Google + AI Overviews.
- Byline on answers pages when expert_take is present, linking to
  /team, hidden when dataPending so editorial context never renders
  next to an empty leaderboard.
- noindex on awaiting benches so Prom outages do not surface thin
  placeholder pages to crawlers.
- Optional Zod fields positioning + expert_take + expert_take_reviewed
  with noAiDashes refine so future drafts stay in policy.
- Sitemap prebuild script emits real git-commit timestamps for the
  8 editorial pages so lastmod stops resetting to build time on every
  unrelated Vercel deploy.
- scripts/editorial-check.sh lints the two new fields for em/en
  dashes, marketing adjectives, pronouns, year references.

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

* alternatives: render positioning + clean paragraph line-wrap (#1250)

Two fixes on top of #1249:

1. Add a Positioning block on /alternatives/[slug]/page.tsx that
   renders the newly-added field. Phase 1 added the Zod schema
   field but never wired the UI, so the content sat in YAMLs
   invisible on staging.

2. Rewrite the 23 positioning + 8 expert_take YAML blocks as a
   single line per paragraph. Previous version hard-wrapped at
   180 chars, which YAML literal (|) preserves as newlines,
   splitting words mid-token in the rendered HTML.

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

* hotfix: strip glued expert_take_reviewed from status line (#1251)

* alternatives: render positioning + clean paragraph line-wrap

Two fixes on top of #1249:

1. Add a Positioning block on /alternatives/[slug]/page.tsx that
   renders the newly-added field. Phase 1 added the Zod schema
   field but never wired the UI, so the content sat in YAMLs
   invisible on staging.

2. Rewrite the 23 positioning + 8 expert_take YAML blocks as a
   single line per paragraph. Previous version hard-wrapped at
   180 chars, which YAML literal (|) preserves as newlines,
   splitting words mid-token in the rendered HTML.

* fix: strip glued expert_take_reviewed from status line

The previous rewrite regex dropped the newline before existing
expert_take_reviewed values without adding a proper break, so
'status: live' and 'expert_take_reviewed: ...' ended up on the
same line, failing YAML parse and killing the build export step.

Also collapsed duplicate expert_take_reviewed lines that landed
during the double-run of the rewrite script.

---------

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

* hotfix: strip glued status line in 8 answer YAMLs (#1252)

Fix that landed in a prior branch was lost during a squash-merge
sequence. Reapplying directly off dev to guarantee it lands.

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

* answers: drop visible byline on expert_take blocks (#1253)

The visible "By Florent Tapponnier, reviewed <date>" byline
implied authorship of every expert_take paragraph and dated the
review with a fabricated timestamp, both editorially fragile.

Keep the E-E-A-T signal at the schema layer only: Person JSON-LD
on Organization.founder, StatisticalReport.contributor, TechArticle
author + reviewedBy, and QAPage suggestedAnswer.author. Search
engines see the human attribution where it counts; readers see the
editorial commentary without a personal claim on every crypto
opinion the site publishes. Removes the unused Byline component too.

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

* drop bench 080 tokenized-stock-arb-latency (Rob Chain pools too illiquid) (#1254)

5 of 11 tickers had frozen pool prices (no swap activity). Remaining 6
had p99 pinned at the 32min histogram cap because arbs rarely close
within the settle window. Robinhood Chain launched Mar 2026, tokenized
equity market still too young for a meaningful arb-latency bench.

Removed:
  * benchmarks/tokenized-stock-arb-latency.yml
  * harnesses/tokenized-stock-peg/cmd/script/arb_latency.go
  * arb_tracker calls + closeStaleOnStateChange in main.go

Kept: bench 077 tokenized-stock-peg + 079 weekend-drift (static peg
measurements) stay live on prod as the honest signal for this cluster.
Slug in REMOVED_BENCH_SLUGS for 410 Gone. Cache keys bumped.

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

* jsonld: add 'name' to TechArticle nodes for Google Rich Results validation (#1257)

GSC flagged 'Missing field name' on aggregator-head-lag JSON-LD (and by extension every bench slug page + every per-chain page + the methodology hub — all TechArticle nodes shipped only 'headline'). Schema.org Article inherits 'name' from CreativeWork; Google's Rich Results validator treats it as required even when 'headline' is present. Add 'name' alongside 'headline' with the same value on all three affected builders.

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

* share-card: propagate region/kind/venue filters + compose dynamic title (#1260)

Feedback from Maxime: exported images do not adapt to the active
filters on the bench page. A share-card exported from
/benchmarks/rpc-capabilities?chain=ethereum&region=sgp used to render
just 'Fastest free public RPC ...' with cross-chain data; now the
loader picks up the region filter and the title reads 'Fastest free
public RPC ... Ethereum · Singapore' with the correctly scoped data.

Change:
  * share-card route reads region, kind, venue URL params (symmetric
    with the existing chain param). Each maps to a spec dimension and
    forwards to the materialize loader so the data is filtered too,
    not just the title.
  * A composed contextParts string is built from the resolved dimension
    labels and appended to the aggregate title. Empty when no filter
    is active, no trailing separator on the default view.
  * The composed title is overlaid onto the benchmark passed to each
    render (spread with {...b, title: displayTitle}) so all 5 templates
    (ranking, leaderboard, snapshot, headline, compare) pick it up
    without any render-signature changes.
  * share-section + share-section-modal read live URL params for
    region/kind/venue in addition to chain when composing the card URL,
    so a modal opened from a scoped bench page passes the same scope
    to the exported PNG.

Follow-up (not in this PR): view / range / topN selectors, per-bench
UI in the modal to expose the dimension pickers explicitly. This PR
is the plumbing; the modal UX for exposing new pickers is next.

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

* Revert "share-card: propagate region/kind/venue filters + compose dynamic title (#1260)" (#1261)

This reverts commit b0ce3ef564fe4ca2aca905245c0d4dd1c6373054.

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

* Revert "Revert "share-card: propagate region/kind/venue filters + compose dynamic title (#1260)" (#1261)" (#1263)

This reverts commit d7f0fcbbbb6ad0db967c285fd23f99ca76b066f5.

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

* aggregator-head-lag: peer-activity clause (on/chain,region) fixes pool-silent false positives (#1265)

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

* share-card: filter negative p50 in sortByP50 (fixes HTTP 500 on RPC benches) (#1266)

Commit c03c599 relaxed the sortByP50 filter from `p50 > 0` to `p50 !== 0` to support the negative-p50 'warming up' sentinel introduced for funding benches on the /products page. The relaxation leaked into the share-card render path, where negatives crash Satori: negative p50 values fed to Math.max produce a negative maxP50, which inverts every bar height ratio and blows up the JSX with an opaque 'Spread syntax' error mid-render.

Symptoms observed: /benchmarks/{rpc-capabilities,polkadot-rpc,ws-head-latency-ethereum,evm-block-builders}/share-card?template=ranking all returning HTTP 500. Working benches (network-coverage, perp-fees, oracle-deviation) had no negative sentinel values in their data.

Fix restores `p50 > 0` in the share-card filter. The negative sentinel handling stays on the /products page where it was designed to render (as 'warming up'). Share-card treats negatives as noise and drops them, same as zeros.

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

* share-modal: explicit dimension pickers (chain / region / kind / venue) (#1267)

Feedback from Maxime: user needs to be able to pick chain/region/kind/venue from within the share modal, not just inherit whatever the URL had when opening. Also the composed card title must reflect the pick in real-time.

Adds a DimensionRow chip component rendered above the template tabs. One row per dimension the bench declares (chainDims / regionDims / kindDims / venueDims). Each row: 'Label' + 'All' chip + one chip per option. Clicking a chip toggles state; clicking the active pick clears back to All.

State is seeded from the live URL on mount so the modal opened from a scoped bench page starts on the same scope (unchanged behaviour). User can override in-modal by clicking a different chip; state updates re-render the card preview with the new filter forwarded to the loader.

The cardSrc URL builder now reads from picker state instead of the live URL - previous implementation re-read the URL every render which meant the modal's own state changes couldn't propagate. Same URL param names on the share-card route: no server-side change needed.

Bench without dimensions: block hidden entirely, modal looks unchanged.

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

* aggregator-head-lag: ws_connected guard filters out harness-reconnect false positives (#1269)

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

* hotfix(share-card): revert region/kind/venue passthrough + title overlay (#1270)

The region/kind/venue param plumbing (added via #1260 → reverted via
#1261 → re-added via #1263) and the display-title overlay via
{...b, title: displayTitle} still cause HTTP 500 on rpc-capabilities
share-card and other benches with dimensions. My sortByP50 fix
(#1266) turned out not to be the actual root cause; the runtime error
persists after that fix + the modal picker PR (#1267).

Reverts the route.tsx changes only:
  - Restore original getBenchmark({chain}) call (no region/kind/venue
    forwarding)
  - Restore original switch that passes plain benchmark/filteredSafe
    to renders (no {...b, title: displayTitle} spread)
  - chainLabel still passed through (unchanged from pre-Maxime-feedback)

Keeps the modal DimensionRow pickers (#1267) intact for a future
retry once the true root cause is found. They currently look for
matching URL params but the server-side won't act on them until the
route.tsx re-adds forwarding safely.

Bug still open on rpc-capabilities/polkadot-rpc/ws-head-latency-*/
evm-block-builders share-cards, but now user-visible functionality
(chain filter, existing templates on non-broken benches) is fully
restored to pre-Maxime-feedback behaviour.

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

* aggregator-head-lag: strip ws_connected clause from geckoterminal (REST-only, no ws metric) (#1272)

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

* share-card: fix HTTP 500 on RPC benches (Satori AVIF/WebP + CSS var crashes) (#1273)

Two independent bugs causing pre-existing HTTP 500 on rpc-capabilities,
polkadot-rpc, ws-head-latency-*, evm-block-builders share-card
endpoints. Reproduced locally with bun dev + shimmed try/catch to
unmask the mid-stream Satori errors.

Bug 1: Satori (the JSX-to-SVG renderer next/og uses) only decodes
PNG / JPEG / SVG. Feed it AVIF or WebP bytes and it throws
'TypeError: u2 is not iterable' mid-render, surfacing as opaque
HTTP 500. publicnode.avif, drpc.webp, lava.webp all trigger this on
the RPC benches. Fix: skip AVIF/WebP extensions in
getProviderLogoDataUrl, fall back to the initials chip.

Bug 2: chipBackground / chipTextColor from src/lib/brand.ts return
CSS var references (e.g. 'var(--color-ink-soft)') when the provider
has no registered brand color. Satori evaluates var(...) to the
CSS-wide 'initial' keyword and rejects 'background: initial',
crashing the whole PNG. evm-block-builders is a full-cohort case
(Titan, BuilderNet, Quasar, Eureka, Builder+, Vanilla — none have
brand colors or logos). Fix: resolve var(...) fallbacks to the
concrete INK_SOF…
Flotapponnier added a commit that referenced this pull request Jul 25, 2026
* solana cohort: solana-official logo alias, leorpc logo + light chip, registry entries

* nft harness: keyless OpenSea mode, empty x-api-key header caused 401s (#1136)

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

* nft harness: delist rarible, free tier is 100 requests per month (#1137)

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

* robinhood chain: chains hub entry, chain-kpis, l2-block-time (HTTP poll mode), network-fees

* bridge-monitor: on-chain balance fallback + degraded flag

* bridge-monitor: self-healing metrics + slack notifiers

* bridge-monitor: auto-rebalancer, corrective transfer via cheapest bridge

* bridge-monitor: tier downgrade ladder + rebalancer wiring in pre-flight

* bridge-monitor: stuck-fund reaper, hourly stranded gauge + corrective transfer

* bridge-monitor: gas auto-top-up via LiFi same-chain swap, gated by default

* bridge-monitor: unit tests for refill route, ladder, stranded hours; fix tracker reset

* bridge-monitor: keyless dry-run support for reaper + RunDryRun

* bridge-monitor: comment style cleanup in pre-flight docs

* bridge-monitor: daily spend tracker keyed by UTC date, persisted; execution mutex primitive

DailySpentUSD only ever incremented and was zeroed by every restart, so a
crash-loop minted a fresh budget each time. The tracker resets exactly at
UTC midnight, persists (date, spent) to SPEND_STATE_PATH (default
./spend-state.json, not /tmp) and is mutex-guarded. Also declares the
package-level single-flight lock and the FAILED_TX_FEE_ESTIMATE_USD
(default 0.50) helper for gas bled by failed broadcasts.

* bridge-monitor: fix double-send on in-flight rebalance, slot-wide budget, single-flight lock

Fund-loss fixes from hostile review:
- a corrective transfer whose deposit broadcast but whose bridge status
  never resolved is now TERMINAL for the scheduler slot (funds are likely
  still in flight, a retry double-sends); only pre-broadcast failures may
  consume another attempt. executeOnBridge keeps TxHash on error paths so
  callers can tell the two apart.
- ONE corrective-transfer budget (max 2) per scheduler slot, shared across
  all downgrade-ladder rungs (was per rung: up to 6 transfers per slot).
- package-level execution mutex: scheduler slots own the wallets end to
  end, the reaper TryLocks and skips its tick instead of queueing.
- broadcasts that failed or timed out after getting a TxHash book a flat
  FAILED_TX_FEE_ESTIMATE_USD against the daily cap instead of bleeding
  unaccounted gas; all spend goes through the persisted UTC-day tracker.
- gas top-up now honors MaxDailySpendUSD before broadcasting.

* bridge-monitor: SIMULATE_BALANCES only honored in dry-run mode

The reaper (and any other consumer) used to accept simulated balances even
in production with broadcast-capable keys, so phantom numbers could
green-light real transfers. SimulateBalances now takes the execution mode
and returns nil outside dry-run; startup logs a warning when the flag is
set in a broadcast-capable mode.

* tx executor: preserve hash on ambiguous send errors, split dry-run spend state

* executor: add missing chain label, first real single-test panicked post-fill (#1141)

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

* bridge-monitor: point Mobula calls at demo-api.mobula.io (#1142)

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

* tokenized-stock-peg: bench 076, onchain tokenized equities vs Nasdaq reference (staging gated)

* spec: missing store blob for a live bench throws instead of caching the miss (bnb-rpc awaiting incident) (#1144)

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

* tsp: parse the flat spark shape (close/timestamp arrays), log empty parses

* tsp: drop invalid rank_matrix_query, 1m reference candles, fee-band + USDG methodology notes

* rwa: new category, tokenized-stock-peg moves in, 11 equity logos + registry pages

* rwa: xstocks-peg (077) + usdy-nav-basis (078), issuer label on the tsp metric family

* rwa: complete product pages, hood/qqq/coin/orca/pyth logos + dual-issuer descriptions

* registry: googl links to google.com, abc.xyz 403s aggressively

* cardano: cache koios epoch params 1h (public tier was exhausted); stellar: protocol-minimum 5 reserves

* fix(store+bridge-monitor): fail-fast on missing blob + sub-50ms histogram buckets (#1152)

* spec: missing store blob for a live bench throws instead of caching the miss (bnb-rpc awaiting incident)

* bridge-monitor: sub-50ms buckets + nanosecond precision on Observe

Histogram Buckets grew from [50, 100, 200, 500, 1000, 2000, 5000, 10000]
to [10, 25, 50, 100, 200, 500, 1000, 2000, 5000, 10000]. Near Intents
on solver-cached corridors (HyperCore in particular) returns via a
1Click coordinator fast path in <50ms, and the previous scheme lumped
every one of those observations into (0, 50] so histogram_quantile
linearly interpolated to ~25ms regardless of true value. The p50 shown
on /benchmarks/bridge-quote-latency?chain=HyperCore was therefore a
bucket floor, not a measurement. Sub-50 buckets let the real bimodality
surface.

Also swap Observe(float64(quoteLatency.Milliseconds())) for
Observe(float64(quoteLatency.Nanoseconds()) / 1e6) across all 6
per-bridge observers (across, debridge, lifi, mobula, nearintents,
relay). int64 Milliseconds() truncates every sub-1ms round-trip to 0,
same bug class as the RPC harness fix in PR #1128. Not a hot path here
(network RTT dominates), but keeps precision consistent with the new
low buckets and cheap to fix while we are already touching each file.

Bench YAML methodology + FAQ updated to reflect the new bucket set.

---------

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

* fix: 3 machine-readable surface bugs (citable unit scaling, stat filters, draft asOf) (#1154)

* fix: 3 machine-readable surface bugs surfaced by GEO audit

1. /api/citable/{date} unit scaling.
   Snapshot route emitted `value: 627` for `aggregator-head-lag` while the
   live /api/citable emitted `value: 0.63` on the same measurement. The
   live route already wraps with valueInDeclaredUnit(raw, b.unit) but the
   snapshot route was left with the raw internal-ms value. Wrap the same
   way; sub-1 s benches now render in the declared `s` unit on both routes.

2. /api/stat/{slug} silently ignored ?chain= / ?region= / ?kind= / ?venue=.
   getBenchmark accepted a filters object but the route never parsed the
   URL. A citer asking /api/stat/rpc-capabilities?chain=ethereum got the
   cross-chain aggregate. Parse the four dimension params, pass through
   to the loader; unknown values fall back to unfiltered inside the
   loader. Also widen getBenchmark options type to include kind and
   venue — the underlying loadBenchmark already supports both.

3. Draft benches leaked a fake freshness signal.
   Draft placeholder set lastRunAt to new Date for type safety
   (Benchmark.lastRunAt is non-null), and both citable routes passed it
   straight through as asOf. LLM crawlers treating asOf as ground truth
   would think a draft bench was measured every minute. Null asOf in the
   JSON when status is draft; the internal type stays intact.

* fix: propagate draft-asOf null-out to /api/stat, MCP, JSON-LD dateModified

The GEO audit review found the initial fix in commit f6f84b8 only covered
the two /api/citable JSON surfaces, but the same "draft benches spoof
freshness" class of bug lives on three more machine-readable channels
that LLM crawlers also key on:

- /api/stat/{slug} asOf field.
- MCP tool responses (list_benchmarks, get_benchmark, resource template,
  and the plain-text "Last sample" line).
- JSON-LD dateModified on the bench page, per-chain sub-page, answer
  page and alternatives page — Google, Bing, Perplexity all consume
  dateModified as a freshness ranking signal.

Centralize the guard as citableAsOf(b) in @/lib/citation, rewire every
call site. Draft benches now uniformly omit or null the freshness
timestamp; live benches unchanged. On the visible /answers page,
the "Data as of..." line and its <time dateTime> anchor also hide for
drafts rather than displaying the wall-clock placeholder.

Follow-up to PR #1154 (commit f6f84b8) covering the surfaces the review
agent flagged as still spoofing.

---------

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

* feat: /api/compare/{a}/{b} + openapi.json coverage (Wave 1 P0/P1) (#1156)

* feat: /api/compare/{a}/{b} + openapi.json coverage

Two GEO surfaces flagged by the audit as high-value gaps.

## /api/compare/{a}/{b}

Head-to-head comparison as a single citable payload. LLMs asked "Mobula
vs Codex head lag" would otherwise need two /api/stat calls plus their
own delta reasoning; this endpoint returns one flat structure with per
benchmark p50 for each side, the winner on the metric's
higher-is-better convention, and the absolute delta.

The intersection logic mirrors src/lib/related-providers.ts and the
HTML /compare/{slug} page: shared benchmark = both providers appear in
it. Intentionally returns flat leader-p50 only (no chain/region
breakdowns, no cache-heavy fan out) so the agent tool call stays fast.

## openapi.json coverage

Added /api/citable/{date} (was missing despite being a real API
endpoint) and /api/compare/{a}/{b} to the OpenAPI 3.1 spec. Also
documented the dimension query params (chain/region/kind/venue) on
/api/stat/{slug} that shipped in the previous PR but were undocumented.
Added Compare schema; extended Stat schema with `filters` field.

/llms.txt and /llms-full.txt stay outside the OpenAPI surface — they
are text files not RPC endpoints, and OpenAPI consumers do not handle
plain-text specs well anyway.

## citableAsOf helper widened

Type signature relaxed from full Benchmark to
`Pick<Benchmark, "status" | "lastRunAt">` so the slim
ProviderAppearance.benchmark used by the new compare route can pass
through without a downcast. No behavior change on existing callers.

## Notes on items NOT shipped in this batch

- Cache-Control on /llms.txt / /api/citable / /api/llm-context: the
  audit report showed `cache-control: public` and inferred no CDN
  caching, but the source already sets
  `public, s-maxage=300, stale-while-revalidate=900`. Vercel strips the
  `s-maxage` directive from the client-visible header while honoring it
  at the edge; `x-vercel-cache: HIT` with `age: 256` confirms the CDN
  cache is active. Not a real bug.
- .bib / .ris file MIME endpoints: string versions already ship inside
  the /api/stat JSON via cite.bibtex / cite.apa. Marginal upside for
  Zotero-style one-click imports; downgraded to P2.
- /api/stat/{slug}/sparkline.svg: sparkline data already ships as a
  float array in /api/stat and the OG image at /api/og/{slug} covers
  the "embed a chart in an answer" case. P2.
- JSON-LD `Dataset.variableMeasured.value`: current schema emits bare
  strings; upgrading to PropertyValue objects would duplicate the
  measured value already carried by the StatisticalReport node's
  Observation.measuredValue. P2.

* openapi: add totalWins to Compare schema

---------

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

* feat: Wave 1 P2 batch — cite MIME, sparkline SVG, Dataset PropertyValue (#1158)

* feat: Wave 1 P2 batch — .bib/.ris/apa MIME, sparkline SVG, Dataset PropertyValue

Three GEO surface additions the audit downgraded to P2. Each one is
duplicative with an existing channel (cite.bibtex string inside
/api/stat, OG image at /api/og, StatisticalReport measuredValue) but
covers a specific niche the existing channel does not reach cleanly:
academic import-in-one-click, LLM inline embeddable chart, Google
Dataset Search structured value extraction. Ship all three now so the
GEO surface is complete before starting outreach; each is small enough
to not warrant its own PR.

## /api/cite/{slug}/{format}

Per-bench citation with the correct MIME type. Four formats:
  - bib -> application/x-bibtex (Zotero, LaTeX)
  - ris -> application/x-research-info-systems (EndNote, Mendeley)
  - apa -> text/plain (ready-to-paste APA string)
  - txt -> text/plain (ready-to-paste plain attribution)

Content is the same as the cite.* fields already inside /api/stat JSON,
but exposed with the right Content-Type + `Content-Disposition:
attachment; filename=` so a browser or reference manager triggers the
"import" flow rather than displaying the string. Added RIS to CiteBundle
in src/lib/citation.ts (TY GEN neutral fallback, AU / TI / PY / UR / Y2
/ ER fields, CRLF line endings per RFC convention).

## /api/sparkline/{slug}

Standalone SVG sparkline of the current leader's 24 h series. Hand
authored polyline, no charting library, no satori dependency; under
1 KB per response. Query params: `w` (40..1200, default 240), `h`
(20..400, default 60), `theme=light|dark`. Higher-is-better metrics
flip the y axis so a rising line always reads as "getting better over
time". Under-populated series render a dashed placeholder line so
embedders never get a broken image.

Cases this reaches that /api/og and the raw JSON sparkline array do
not: img/svg inline embeds in Perplexity Pages, ChatGPT answers, dev.to
posts, Notion pages. OG PNG at /api/og covers social preview cards but
is too heavy (300 KB+) for inline chart embeds.

## Dataset.variableMeasured -> PropertyValue

Bare-string array in Dataset JSON-LD upgraded to include structured
PropertyValue objects when the current leader has real numeric
aggregates. Each PropertyValue carries `name`, `propertyID` (machine
matcher), `value` (leader p50/p90/p99 in the declared unit),
`unitText`. sample_size stays a bare string — it labels an axis, not a
measurement. Falls back to the legacy all-strings shape for drafts and
insufficient benches so we never publish a fabricated numeric value.

New helper: buildBenchVariableMeasured() in src/lib/dataset-jsonld.ts.
BenchDatasetInput.variableMeasured now accepts
Array<string | VariableMeasuredValue>. `keywords` extraction pulls the
`.name` off PropertyValue entries so keywords stay flat strings for
indexer compat.

## openapi.json

Both new endpoints registered under paths, so agent frameworks
discovering the API through OpenAPI can call them.

* sparkline: XML-escape bench title + provider name in SVG

---------

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

* bridge-monitor: Mobula EIP-712 signature-confirm flow (fixes stuck deposits) (#1160)

* bridge-monitor: implement Mobula EIP-712 signature-confirm flow (fix stuck deposits)

* mobula sig: inject EIP712Domain type definition, API omits it

---------

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

* robinhood chain vague 1: weekend drift (079) + arb latency (080) + subsidy tracker (081)

* subsidy is a chain KPI, not a bench: kill standalone spec, add 3 cards to /chains/robinhood strip

* pm-api-latency: add Predexon (bench 6th provider), fresh key deployed on the 3 Railway probe regions

* usdy: 3-retry on Orca RPC to fix 40% success rate (#1168)

publicnode.com burst-limits the VPS on getAccountInfo, dropping
~60% of samples. Two retries with 500ms backoff bring hit rate to
>95% without switching endpoints. Also add commitment=confirmed
to reduce slot-miss null returns.

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

* bench vague 2: ship 081-085 (ws latency, oracle freshness, rpc reliability, indexer latency, block builders) (#1170)

* add bench 081 ws-head-latency: WS block-push lag harness + spec

* add bench 084 indexer-latency: HyperSync vs The Graph vs Alchemy time-to-queryable

* idx-latency: only classify error when every poll errored, else timeout

* bump bench-set cache keys for 084 (lockstep rule)

* add bench 082 oracle-freshness: staleness metrics in oracle-deviation harness (multi-chain chainlink, pyth publish_time fix, redstone), bump bench-set cache keys

* oracle-freshness: count unsupported mainnet feeds in freshness error counter

* add bench 085 evm-block-builders: ETH builder share via extraData + relay bidtraces + Arb/Base soft-conf lag, bump bench-set cache keys

* ebb: don't count historical relay payloads on first poll, retry arb calibration on zero support, reset WS backoff after stable session

* add bench 083 rpc-reliability: consensus lag, hash quorum, fixed-vector integrity in rpc-capabilities harness, bump bench-set cache keys

* bench 083: clamp_min rank_matrix_query so zero-incident providers keep cell ranks

* gate benches 081-085 behind staging until 48h clean data

* drop shipped RWA slugs from prod gate (live on main since #1162/#1167)

---------

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

* hyperliquid frontend page: add ProviderLogo in the hero next to the name

* hl frontends: add slash, topdog, markets-mobile logos + registry entries; dark-mode chip for slash + markets-mobile

* idx-latency: swap Alchemy for Mobula in the cohort (paid-plan key, bench races $0 pipelines only) (#1176)

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

* fix(bench): sample-size PromQL + leader reliability floor (#1175)

* fix(bench): sample-size PromQL + leader reliability floor

Two bench-correctness fixes surfaced by an audit of the oracle and
gas surfaces we plan to cite in outreach.

Bench 025 (oracle-deviation):
- sample_size was 'count(count by (source) (ocb_oracle_price))'
  which returns the count of distinct sources (3 or 4), not the
  count of samples over the window. The citable API therefore
  reported sampleSize=3-4 on every pair, at odds with the spec's
  'polled every 30s' claim. Rewrites the query to
  'sum(count_over_time(ocb_oracle_price[24h]))' across all 10
  pair entries so the number reflects actual poll volume.

Leader ranking:
- citationCandidates() now excludes providers with a measured
  success rate below 50 percent from the leader pool. Before this
  fix, gas-estimation showed Owlracle as #1 with successRate=6.48
  and a p50 gap of 0.001 gwei: technically accurate on the rare
  successful poll but useless as a leader claim. Falls back to the
  full live pool when every provider is under the floor so a
  totally degraded bench still surfaces a best-of-bad-options
  leader rather than vanishing. Freshness benches without a
  success query default to 100 percent in the loader so the guard
  is inert there.

Adds src/lib/citation.test.ts with 5 tests covering the Owlracle
scenario, higher-is-better ranking, and the degraded-pool
fallback.

* fix(bench): propagate reliability floor to rankings + template + hub

Follow-up on the previous commit. Review flagged that filtering the
leader without filtering the ranking arrays leaves self-contradictory
JSON: /api/stat can report leader=Etherscan while rankings[0]=Owlracle
on the same document.

Exports citationCandidates and adds rankedCandidates helper that
returns the same eligible pool sorted by the bench's higher_is_better
direction. leader() and fieldValue() now share it.

Wires the shared helper into every surface that emits a rankings-like
list or a best-of claim:

- src/app/api/stat/[slug]: rankings array now sorted from
  rankedCandidates so rankings[0].slug always equals leader.slug on
  the same JSON.
- src/app/api/llm-context: the Rankings numbered list under each
  bench matches the Headline sentence above it.
- src/app/api/mcp/[transport]: get_benchmark tool response and the
  openchainbench://benchmark/{slug} resource both share the pool.
- src/lib/bench-template: {{best_name}} / {{best_p50}} / {{worst_name}}
  tokens in bench copy resolve against the same pool so a bench body
  never crowns a provider its own headline excludes. Per-slug tokens
  {{p50:some-slug}} still resolve against the raw live pool so unknown
  tokens still fall through.
- src/lib/providers: rankProviders (drives /products hub wins count
  and per-chain leadership chips) now runs on the reliable pool.

Adds a consistency test locking that rankedCandidates[0].slug always
matches leader().slug on the same bench. Total: 6 tests in
citation.test.ts, 95/95 pass across src/.

Uses (r.successRate ?? 100) for the numeric guard to future-proof
against ProviderResult shapes that skip the field. Load path still
defaults to 100 (materialize/load.ts:748) so this is inert today, but
the extra ?? removes the fragility.

/api/compare/[a]/[b] is intentionally left unfiltered: the user
picks the two providers explicitly, so the head-to-head is a
requested comparison rather than a leader claim. Applying the floor
there would drop rows the user explicitly asked to see.

* fix(hub-card): honor citation leader in the hub grid headline

Follow-up review flagged that benchmark-card.tsx re-sorted b.results
locally and picked results[0], so a bench where citation demotes the
raw-p50 winner (e.g. Owlracle on gas-estimation) would show the
demoted provider on the hub grid while the bench page names the
correct leader. Two documents contradicting each other in one click.

Adds leaderSlug: string | null to BenchmarkCardData and populates it
at the projection boundary via leader(b), which already applies the
reliability + insufficient-sample filter. The card picks the leader
by matching that slug in the projected results, and falls back to the
raw best when the projection returned no leader (draft, insufficient,
or all providers filtered out) so a card still renders a headline
value in every state.

Kept the local sort so the fallback path and any downstream chip
using sorted[0].name keeps working.

Test suite unchanged: 95/95 pass across src/. No test needed since
this is a projection-boundary wire-up.

---------

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

* fix(registry): remove duplicate topdog entry (#1177)

Commit 349b786 (hl frontends: add slash, topdog, markets-mobile logos +
registry entries) added a 'topdog' entry at line 313 without noticing a
pre-existing 'topdog' entry at line 1061, breaking staging build with a
duplicate object literal type error.

Keeps the newer entry (Telegram bot at t.me/topdog_trade_bot, HL
frontend context) since it was the one intentionally added for the
Hyperliquid frontends leaderboard chip work. Drops the older Discord
signal bot entry (topdog.gg): the bench spec at
benchmarks/hyperliquid-frontends.yml has slug: topdog with name TopDog
and matches the newer, HL-native product.

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

* vague2 QA: unit sec for true-seconds benches (fixes 0.0s heros), soften 080 findings, drop dead 085 builder rows, 083 expected_n 40 (#1178)

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

* add bench 086 polkadot-rpc: first Substrate chain in the RPC cluster (#1179)

Extends rpc-capabilities to Polkadot, the first non-EVM, non-Solana
chain in the cluster. Substrate JSON-RPC via chain_getHeader (returns
hex-encoded block number matching EVM's eth_getBlockByNumber shape),
staleness by relay-block gap (40 blocks, ~4 min at Polkadot's ~6 s
cadence).

Harness changes:
- Chain.Kind gains "polkadot" alongside "solana" and default (EVM).
- New callSubstrateHeader: chain_getHeader POST, parses hex block
  number, returns parentHash as the observed hash. Same latency
  measurement + result classification as the EVM path.
- probeOne switches on Kind (was if solana / else). polkadot uses
  polkadotStaleBlockGap = 40. Consensus observation is skipped
  because chain_getHeader returns parentHash (N-1), not the current
  block hash (deriving current hash needs Blake2 over the SCALE-
  encoded header, out of scope).
- Archive loop is skipped for polkadot the same way as solana:
  Polkadot state accesses via state_getStorage keyed by Blake2 storage
  keys, no chain-agnostic depth analog for eth_getBalance-by-depth.

Providers at launch (3, live-verified on 2026-07-15 with four
consecutive chain_getHeader probes each):
- polkadot-official (Parity, rpc.polkadot.io)
- onfinality (public gateway, Web3 Foundation grantee)
- publicnode (Allnodes, extends its multi-chain footprint to
  Substrate keyless)

Audited and excluded from the launch cohort:
- 1RPC: filters chain_getHeader as "Not Allowed" on the privacy
  relay. Revisit if they expose a Substrate-friendly method set.
- Dwellir: 503 Service Unavailable during the audit sweep, revisit
  once the gateway stabilises.
- Ankr: paid Polkadot tier only.
- Chainstack: key-gated on Substrate.
- RadiumBlock: rejects without referral header.
- Grove / Thirdweb: invalid-chain response.

Spec (benchmarks/polkadot-rpc.yml, bench № 086):
- Same schema as moonbeam-rpc / avalanche-rpc etc. so the parent
  rpc-capabilities benchmark, chain cluster page and hub card
  render it identically.
- Methodology documents the Substrate specifics: probe method
  choice, staleness scaling, why no archive-depth or hash-quorum
  columns.
- SEO/finding tokens use the standard {{best_name}} / {{best_p50}}
  / {{p50:slug}} template surface, consistent with other -rpc
  benches.
- Provider tag copy explains OnFinality and Dwellir's Web3
  Foundation grantee status so the leaderboard reads correctly
  for someone who knows the Polkadot infra landscape.

Local checks:
- go build ./... clean.
- go test ./... clean.
- bun test src/ - 95/95 pass across 10 files, including the
  citation reliability floor and consistency tests added in #1175.

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

* product pages: 9 block-builder registry entries + verified logos, oracle logos (pyth/redstone) (#1181)

* products: registry entries + logos for bench 085 block builders

* oracle logos: redstone official logo + pyth/redstone manifest + registry entries

---------

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

* 085: add bobthebuilder provider row (real share, was metrics-only) (#1182)

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

* polkadot-rpc bench 086 + /chains/polkadot + Parity product (#1184)

* add bench 086 polkadot-rpc: first Substrate chain in the RPC cluster

Extends rpc-capabilities to Polkadot, the first non-EVM, non-Solana
chain in the cluster. Substrate JSON-RPC via chain_getHeader (returns
hex-encoded block number matching EVM's eth_getBlockByNumber shape),
staleness by relay-block gap (40 blocks, ~4 min at Polkadot's ~6 s
cadence).

Harness changes:
- Chain.Kind gains "polkadot" alongside "solana" and default (EVM).
- New callSubstrateHeader: chain_getHeader POST, parses hex block
  number, returns parentHash as the observed hash. Same latency
  measurement + result classification as the EVM path.
- probeOne switches on Kind (was if solana / else). polkadot uses
  polkadotStaleBlockGap = 40. Consensus observation is skipped
  because chain_getHeader returns parentHash (N-1), not the current
  block hash (deriving current hash needs Blake2 over the SCALE-
  encoded header, out of scope).
- Archive loop is skipped for polkadot the same way as solana:
  Polkadot state accesses via state_getStorage keyed by Blake2 storage
  keys, no chain-agnostic depth analog for eth_getBalance-by-depth.

Providers at launch (3, live-verified on 2026-07-15 with four
consecutive chain_getHeader probes each):
- polkadot-official (Parity, rpc.polkadot.io)
- onfinality (public gateway, Web3 Foundation grantee)
- publicnode (Allnodes, extends its multi-chain footprint to
  Substrate keyless)

Audited and excluded from the launch cohort:
- 1RPC: filters chain_getHeader as "Not Allowed" on the privacy
  relay. Revisit if they expose a Substrate-friendly method set.
- Dwellir: 503 Service Unavailable during the audit sweep, revisit
  once the gateway stabilises.
- Ankr: paid Polkadot tier only.
- Chainstack: key-gated on Substrate.
- RadiumBlock: rejects without referral header.
- Grove / Thirdweb: invalid-chain response.

Spec (benchmarks/polkadot-rpc.yml, bench № 086):
- Same schema as moonbeam-rpc / avalanche-rpc etc. so the parent
  rpc-capabilities benchmark, chain cluster page and hub card
  render it identically.
- Methodology documents the Substrate specifics: probe method
  choice, staleness scaling, why no archive-depth or hash-quorum
  columns.
- SEO/finding tokens use the standard {{best_name}} / {{best_p50}}
  / {{p50:slug}} template surface, consistent with other -rpc
  benches.
- Provider tag copy explains OnFinality and Dwellir's Web3
  Foundation grantee status so the leaderboard reads correctly
  for someone who knows the Polkadot infra landscape.

Local checks:
- go build ./... clean.
- go test ./... clean.
- bun test src/ - 95/95 pass across 10 files, including the
  citation reliability floor and consistency tests added in #1175.

* polkadot page + parity/onfinality/dwellir products

Wires the /chains/polkadot hub page, provider profiles, and logo
assets that surface once bench 086 polkadot-rpc starts feeding Prom.

- chains.ts: add Polkadot to CHAINS registry (L1, nativeSymbol DOT).
  Auto-populates /chains/polkadot once benches referencing chain="polkadot"
  land. Adaptive KPI strip fills in DefiLlama TVL, DEX volume and stables
  when the chain-kpis harness starts sourcing Polkadot alongside the
  other L1s.
- provider-registry.ts: three new entries for the Polkadot cohort.
    * parity: brand entry for Parity Technologies (Polkadot / Substrate
      implementation shop, operator of rpc.polkadot.io).
    * polkadot-official: endpoint-level entry describing the reference
      rpc.polkadot.io keyless endpoint; providers.ts aliases the slug
      to parity so the /products page collapses under the operator's
      brand.
    * dwellir: brand entry for Dwellir (Web3 Foundation grantee, keyless
      Substrate infra) — excluded from the 086 launch cohort due to a
      503 during the audit sweep but kept in the registry for the
      revisit and for other places their brand might appear.
- providers.ts: add polkadot-official → parity alias and canonical
  brand casings for polkadot / parity / onfinality / dwellir so the
  /products hub, badges, and OG images render the correct name
  without the title-case fallback butchering them.
- logo-manifest.ts: polkadot.png (chain hero), parity.png (product page
  logo), plus polkadot-official → parity slug alias so the endpoint
  chip on the bench page picks up Parity's mark automatically.

95/95 pass on bun test src/. Local build clean.

---------

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

* fix: dedup onfinality key in logo-manifest (already declared line 49) (#1186)

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

* store: last-known-good snapshot fallback (no more awaiting after first publish) (#1188)

publishSnapshot writes an lkg copy without TTL on every success;
readMaterialized falls back to it when the pointer/blob pair misses
(worker outage past blob TTL, DEL race, transient). dataAsOf keeps the
freshness badge honest about age. LKG write is best-effort and never
fails the publish.

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

* chain-kpis: add polkadot to registry (DOT native, stables via Asset Hub) (#1191)

Ships live KPIs on /chains/polkadot. DOT price and mcap flow through
Mobula. Stables card renders via DefiLlama Asset Hub aggregate.
TVL and DEX gauges stay unpublished (relay chain has zero DeFi:
parachain TVL lives under Acala, Moonbeam, Hydration).

Also guards defillama TVL publish to skip zero values so chains that
DefiLlama tracks by name but with an all zero series (Polkadot today)
hide the TVL card instead of rendering $0.

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

* live-feed status: Feed down pill + banner on aggregator-head-lag (#1193)

Short-window changes() probe per provider surfaces silent live feeds
that the 24h quantile aggregate hides (Codex WS blackout 08:23-09:04
UTC today moved the p50 by nothing but the whole feed was mute for
40min). Bench-level probe_ok gate suppresses badges when our harness
side is broken so a Railway crash cannot fake-flag every provider.

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

* series gaps: null the chart line when a feed goes silent (unless changes==0) (#1195)

Companion to feed-down banner: makes the 40min Codex blackout from
this morning (08:23-09:04 UTC) visible on the 24h chart instead of a
flat line covering the outage. Per-series match preserves the
aggregate line as long as one chain still receives events.

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

* add bench 087 ws-head-latency-base + 088 ws-head-latency-solana (#1196)

Per-chain siblings of bench 081 for the WebSocket head-lag cluster.
Same harness (harnesses/ws-head-latency), same race scoring, one
spec per chain to avoid mixing block cadences (Ethereum 12s vs Base
2s vs Solana 400ms) in a single leaderboard.

Base cohort: PublicNode + dRPC keyless endpoints. Tenderly does
not expose a keyless Base WebSocket path.

Solana cohort: PublicNode + Solana Foundation. slotSubscribe
replaces newHeads on the harness side; metric names are shared so
the queries are structurally identical to the EVM specs.

Data already flows via the Paris VPS deployment (chain label carries
base and solana). No harness change needed for this ship. Multi-region
is a follow-up on the parent bench.

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

* ws-head-latency: multi-region (harness region label + spec region tabs) (#1198)

* ws-head-latency: region label via ConstLabels + Railway config

Adds a static region ConstLabel to every ws-head-latency metric,
bound at process startup from OCB_REGION (preferred) or
RAILWAY_REPLICA_REGION (Railway auto-set), with eu-west fallback
matching the historical single-vantage Paris VPS deployment.

No observation site needs to change: the label is baked into every
counter, gauge and histogram at registration, so all existing
WithLabelValues(provider, chain) calls stay identical.

Adds railway.toml so Railway can build the harness from Dockerfile
with a /health check, matching the rpc-capabilities service layout.

Follow-up: create the Railway ws-head-latency service with 3
replicas (us-east, eu-west, sgp) and OCB_REGION set on each, then
decommission or leave the Paris VPS container as a 4th vantage.

* ws-head-latency specs: add region dimension tabs (081 + 087 + 088)

Adds `dimensions.region` block + `rank_matrix_query` on provider,
region + per-provider `regions:` sub-arrays with region-scoped
queries on the 3 ws-head-latency specs, following the polkadot-rpc
pattern.

Depends on the harness region label change (previous commit): every
metric now carries a region ConstLabel so the region-scoped
histogram queries return values as soon as multi-region replicas
publish. Until Railway 3-replica deploy lands, only region=eu-west
has data and the other tabs render empty; the All regions tab keeps
working because the aggregate query is unscoped by region.

---------

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

* ws-head-latency: align region env var with rpc-capabilities (REGION not OCB_REGION) (#1200) (#1201)

Every other multi-region harness on Railway (rpc-capabilities, aggregator-head-lag, evm-swap-quoting, pm-rate-limits, etc.) reads REGION from env. Making ws-head-latency read REGION too keeps the Railway service config uniform across the whole harness fleet.

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

* chart: red band overlay for null-bucket downtime windows (#1203)

Companion to #1195 (series null gaps). The gaps themselves render as
line breaks but a break can read as 'the chart missed a sample'. A
soft red column behind the break spans the exact outage window and
makes the reader understand it was a real feed-side silence. Per
line + per contiguous gap range, extended by one step so 1-bucket
outages stay visible instead of collapsing to a zero-width sliver.
Hidden for excluded (legend-toggled) providers.

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

* rename ws-head-latency -> ws-head-latency-ethereum for sibling slug parity (#1204)

081 becomes ws-head-latency-ethereum so the 3 per-chain benches in the head-lag cluster share the same shape: ws-head-latency-ethereum / -base / -solana. The old ws-head-latency slug is dropped (was staging-gated via REMOVED_BENCH_SLUGS, never live on prod, no external SEO to preserve).

Ungated on prod at the same time so the new slug ships alongside the base + solana siblings that are already live. Sibling text references updated to the new URL. Cache keys bench-unfiltered and all-benchmarks bumped so cached v29/v32 entries do not keep the old slug alive.

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

* network-coverage: drop coinstats (free tier credits exhausted) (#1206)

CoinStats /wallet/blockchains returns HTTP 406 "Credits limit reached" on every fetch: the free tier quota is spent below our 6h cadence and the provider never publishes a network count. Dropping until a paid key is provisioned.

Removes the coinstats.go adapter, the CoinStatsAPIKey config field, the coinstats entry in main.go, and the coinstats provider row + prose mentions in the spec. Cohort drops 6 -> 5 providers (GeckoTerminal, Codex, Mobula, CoinPaprika, Dune via Sim API).

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

* Revert "network-coverage: drop coinstats (free tier credits exhausted) (#1206)" (#1208)

This reverts commit d295f7168da981078164119e508ca9546ed69fa2.

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

* downtime bands: provider-colored tint + name/silent label + edge markers (#1210)

Feedback on #1203: the neutral red band read as 'something happened
here' without telling the reader WHO. Tint each band in the
provider's own color, add dashed edges at the outage boundaries, and
label 'PROVIDER · SILENT' above the band when it fits (with a caret
that survives on narrow single-bucket bands). Works uniformly for
Mobula/Codex/Gecko or any future provider with a live_activity
query.

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

* downtime bands: pill labels + vertical stacking on overlap (#1211)

Screenshot review: the small text-only label above the band was still
too subtle to read as 'this provider is down', and 3 simultaneous
provider outages produced overlapping unreadable labels stacked on
the same y. Replace with high-contrast rounded pills (white text on
provider color) that stack vertically when their X-ranges overlap.
Renders as '● NAME DOWN' in bold small caps so a reader gets the
event and the provider identity at a glance.

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

* tsp-arb-latency: lower trigger 50 -> 25 bps + credit sub-poll closures (#1212)

Bench 080 published only 7 samples across 3 of 11 assets in 24h at
50 bps trigger. Two contributing causes fixed together:

1) Trigger from 50 -> 25 bps. 50 bps in 90s is a headline move on
   Nasdaq (earnings, macro prints), not a steady-state event. 25 bps
   is above normal intra-minute noise on liquid stocks and captures
   real arb-worthy moves without collapsing the semantic.

2) Sub-poll convergence credited. When the reference moves past the
   trigger AND the pool is already within band on the same tick, the
   arb closed inside one poll interval. Previously dropped silently;
   now observed at pollInterval (60s) so the fast-arb tail on liquid
   pools stops disappearing from the histogram.

Convergence threshold stays at 20 bps: the "how close is the pool to
fair" question is unchanged, only trigger sensitivity moves. Spec
text (subtitle, methodology, FAQ) updated to 25 bps throughout.

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

* downtime pill: drop the leading dot indicator, center the label (#1213)

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

* downtime pill: 'DATA MISSING' wording + skip leading gaps (#1215)

Two fixes based on 30D view review:

- Leading gaps (Prom retention shorter than the visible window,
  harness started mid-range, provider added recently) rendered as
  a chart-wide band claiming every provider was down for the
  missing period. Skip runs that started before the first observed
  sample.

- Rename 'DOWN' to 'DATA MISSING'. A silent series can be the
  provider going down, our harness losing its WebSocket, or a Prom
  scrape failure — the pill stays neutral instead of blaming the
  provider.

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

* drop bench 084 indexer-latency (harness pruned, spec deleted) (#1218)

HyperSync and The Graph need paid keys for the sustained cadence the
bench expects; Mobula alone left the leaderboard single-provider and
the spec never made it out of REMOVED_BENCH_SLUGS gating. Simpler to
drop the whole cluster than to buy 2 SaaS subscriptions to prop up
a bench nobody has cited yet.

Removed:
  * benchmarks/indexer-latency.yml
  * harnesses/indexer-latency/ (whole directory)

Kept in REMOVED_BENCH_SLUGS as 410-gone so any indexed URL returns
Gone rather than 404. Cache keys bumped so cached v30/v33 entries
that still enumerate the slug age out on next revalidate.

VPS follow-up (separate ops): stop ocb-indexer-latency container,
remove from docker-compose.yml + prometheus.yml scrape config.

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

* video/render: strict Zod body schema before upstream forward (#1216)

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

* mcp: allowlist perp_funding_*, perp_venue_*, perp_execution_* prefixes (#1220)

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

* aggregator-head-lag: drop 'unless changes==0' from series queries (#1221)

Bug: the guard was designed for modern 15s-cadence data, where
changes([15m]) works cleanly. On backfilled historical data compacted
to ~1 sample/hour, changes([15m]) returns 0 even though the gauge is
healthy — the 30D view lost the first 8 days of data to false-drops
(all three providers marked DATA MISSING for a whole week).

Real-time silence is still surfaced by the live_activity scalar
(pill + banner + downtime band), which runs against fresh scrape
cadence and stays accurate. The chart series now shows every point
Prom has, flat during old outages instead of gapped.

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

* seo: 200 placeholders + sitemap threshold buffer (#1222)

- badge/[slug]/[provider]: serve pending SVG (200) instead of 404 when a provider is missing ranking data
- hyperliquid/[slug]: render lightweight placeholder page (200) instead of 307 to /hyperliquid when history blob is missing the builder
- compare adHocPairs: bump inclusion threshold to 3 (brand) / 4 (other) so a single bench flap can't cross both the sitemap gate and the page noindex gate

Fixes 3 recurring Ahrefs issues (broken badge images, noindex-in-sitemap on compare, 3xx-in-sitemap on hyperliquid) all rooted in the sitemap/page ISR race.

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

* aggregator-head-lag: restore outage detection with backfill-safe guard (#1224)

Follow-up to #1221. Dropping the guard entirely restored the 30D
range but silently hid today's Codex morning blackout on the 24H
view (frozen gauge value = flat line, indistinguishable from a
healthy quiet period).

Bring the guard back, gated on count_over_time > 5 samples in the
window. Modern data at 15s cadence carries ~60 samples per 15 min
so the gate fires the moment a WS goes silent. Backfilled data
compacted to ~1 sample/hour has count <= 2 in a 15m window so the
gate stays open and every point Prom kept is served — no more fake
wall-to-wall gap at the start of the 30D range.

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

* ws-head-latency: sub-ms histogram floor + keyed slots for base/solana (#1225)

Fix the leader-collapses-to-2.5ms artifact: histogram_quantile on a
[0,5ms] first bucket interpolates every winner's p50 to ~2.5ms
(chain-independent). Replace with a 0.5ms floor climbing to 10s so
sub-ms wins actually resolve on Solana (~400ms slots) and Base (2s).

Add keyed provider slots for Base (Alchemy/Infura/Chainstack/QuickNode)
and Solana (Alchemy/Chainstack/Helius/QuickNode) so the cohort can
extend beyond keyless once contributor keys are wired. Keyless tier
is saturated on all three chains (audit failed: Ankr, Blast, LlamaRPC,
BlockPI, 1RPC, NodeReal, Omniatech, base.org, extrnode, chainstack-
public — none accepted a keyless WSS handshake as of 2026-07).

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

* ship: ungate bench 085 evm-block-builders on prod (#1227)

Titan leads with sample=6973 in 24h, data healthy since the harness
ship. Removing from REMOVED_BENCH_SLUGS so the slug renders on prod
next revalidate. Cache keys bumped to bench-unfiltered-v32 +
all-benchmarks-v35 so cached entries do not keep dropping the slug
via the old prod gate.

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

* loader: propagate 24h series null-runs to 7d/30d (#1229)

Fine-grained outages (24h step = 20 min buckets) rarely align with
coarser evaluation timestamps (7d step = 2h, 30d step = 12h) so the
Prom query returns a healthy average and the reader sees the pill on
24H but nothing on 7D/30D. Post-process: for every contiguous null
run detected on s24, translate the time-ago range to bucket indices
on s7 and s30 and null every coarse bucket the run straddles.

Contiguity preserved so a single short outage doesn't split into two
adjacent pills after propagation. Same treatment applied to per-region
series.

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

* propagateNullsToCoarser: use absolute time-ago instead of window fraction (#1230)

Bug: the previous math scaled by the fine window fraction, which
projected a 7h-ago outage down to 40h-ago on the 7d grid (the pill
landed at -1.7d instead of -6h). Both grids share 'now' as their
right anchor and only the step sizes differ — take the fine idx's
absolute time-ago and divide by the coarse step to find the
covering bucket.

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

* tsp-arb-latency: drop 5 illiquid tickers (TSLA GOOGL AMZN MSFT SPY) (#1231)

Their Uniswap v4 pools on Robinhood Chain have no arb activity: pool spot stays frozen at the last swap value tick after tick while the Yahoo reference moves normally. Events open on ref triggers but roll over as still_open with zero latency samples. Cohort restricted to the 6 tickers with observable arb activity: NVDA AAPL PLTR META AMD MU.

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

* perp-fees harness: fix Paradex depth-skip, gains SOL slot, dYdX walk order, GMX Subsquid error surfacing (#1233)

- Paradex: cap fill ratio at 90% of visible book (depth=100). Tiers that eat through the tail now return book_too_thin and skip publishing instead of inflated 150-500 bps ghost values.
- gains: only cache USD-quoted pairs during pair discovery, widen scan 60->200. Root cause was a SOL/BTC pair overwriting the SOL/USD cache entry, publishing the wrong feeIndex and uniform 4.333 bps across ETH/BTC/SOL.
- dydx: sort v4-indexer level responses (asks ascending, bids descending) before walking the book. Insertion-order walk produced 1.7-2x inflated headline at 100k/1M tiers.
- gmx: prefix Subsquid errors, surface GraphQL errors[], fall back to unsuffixed positionFeeFactor if the impact-branch field is absent, and error out on zero factor. Prevents the silent-6bps-constant fallback path.

Verified: go build + go vet clean.

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

* downtime bands: opt-in via live_activity, skip on other benches (#1235)

Screenshot review: rpc-reliability rendered a 'LAVANODIES DATA MISSING'
pill even though its spec never declared live_activity — natural
nulls in its series24h (sparse scrapes, backfill edges) triggered
the DowntimeBands render. Gate the render on
benchmark.results.some(r => r.liveStatus != null), which is only set
when the spec declared live_activity. Same gate on
propagateNullsToCoarser so other benches' 7d/30d series stay
untouched.

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

* batch 2: MEV chips + rpc copy + Aster/edgeX + Cardano/Sui/Cosmos/Avax (#1236)

* site: provider_notes chip on ranking rows + rpc-capabilities copy fixes

- validator-yield now surfaces a per-provider chip (MEV inc./MEV excl./no MEV) beside each row so scan-readers can't accidentally compare Solana MEV-inclusive APR against Ethereum consensus-only APR without seeing the caveat
- New optional spec field provider_notes: {slug: string} plumbed end-to-end (Zod schema, TS type, materialize loader, spec overlay, ledger-table render)
- Chip renders only when the note is set, no visual change to any other bench
- rpc-capabilities: '10 EVM chains' copy corrected to 26 chains (24 EVM + Solana + Polkadot) across title/subtitle/seo_intro/abstract/methodology + FAQ
- rpc-capabilities disclaimer expanded to call out that the 'All chains' aggregate rewards single-chain foundation endpoints and to document Cloudflare-eth exclusion (returns -32603 on POST)

* perp-fees harness: add Aster + edgeX venues

- Aster (BSC): Binance-style depth walk against fapi.asterdex.com, hardcoded 5 bps taker (no public fee schedule API)
- edgeX (offchain CLOB): getDepth REST + hardcoded 3.8 bps taker per gitbook docs. Asset→contractId map declared inline (verify on first prod run; wrong ID surfaces as an orderbook fetch error, not a silent 0)
- Both venues probed for ETH/BTC/SOL at all 3 notional tiers ($1k/$100k/$1M) via applyBookTiers
- Closes the biggest coverage gap flagged by the perp-fees GEO audit (Aster is #2 perp DEX by 30d volume)

* validator-yield harness: add Cardano, Sui, Cosmos Hub, Avalanche

Closes the 'Highest staking yield' coverage gap: the bench previously ranked only Solana, Ethereum and Hyperliquid but AI answers to that question almost always include Cardano ($14B staked) and other top chains.

- Cardano (Koios API): top-50 pools by stake, net = ρ×(1-τ)×(1-margin). Simplified vs full a0/k/saturation formula but lands within ~50-100 bps of pooltool.io.
- Sui (public RPC suix_getValidatorsApy + suix_getLatestSuiSystemState): all active validators, apy is already net of commission per Sui docs.
- Cosmos Hub (LCD REST): top-200 bonded validators, net = inflation×(1-communityTax)/bondedRatio×(1-commission).
- Avalanche (P-Chain platform.getCurrentValidators): top-100 by stake, base 8.5% APR × uptime × (1 - delegationFee/100).

Reuses existing ocb_validator_* gauges from metrics.go (no new metrics, no go.mod changes). Env-overridable endpoints declared inline in each fetcher matching solana.go style. Shortcuts (uptime=100% for Cardano/Sui/Cosmos, hardcoded community_tax=2%, base 8.5% for Avalanche) documented in code.

---------

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

* rpc-capabilities: add 4 keyless providers (ThirdWeb Blast Gateway.fm bloXroute) (#1238)

Broadens the keyless cohort for benches 010 (rpc-capabilities), 083
(rpc-reliability) and the per-chain RPC benches. All 4 providers
verified live returning valid eth_getBlockByNumber on their
respective chains before inclusion.

New (provider, chain) cells:
  ThirdWeb    8 chains (ETH POL ARB OP BASE BSC AVAX LINEA)
  Blast API   4 chains (ETH ARB BASE BSC) — POL OP AVAX LINEA are 403 paid tier
  bloXroute   3 chains (ETH BASE BSC)
  Gateway.fm  1 chain  (ETH)

Total = 16 new cells. Metrics auto-flow into bench 083 (rank_matrix
groups by provider+chain) and the per-chain leaderboards after the
Railway rebuild picks up main.

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

* ws-head-latency: add Onfinality Ethereum to keyless cohort (#1241)

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

* drop oracle-freshness (082) spec + sync REMOVED with main state (#1245)

Oracle-freshness deleted from the site: apples vs oranges (Chainlink push vs Pyth/RedStone pull measure different things), splitting into pull/push sub-benches would leave each with 1-2 providers, redundant with bench 025 oracle-deviation which measures the same feeds via CEX deviation with a more actionable signal.

Slug kept in REMOVED_BENCH_SLUGS as 410 Gone. Harness code in oracle-deviation retained (metrics still emit but no site reader).

Also syncs dev's REMOVED_BENCH_SLUGS with main: removes tokenized-stock-arb-latency + rpc-reliability (shipped to prod earlier today) so dev matches main state.

Cache keys bumped bench-unfiltered-v34 + all-benchmarks-v37.

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

* SEO/E-E-A-T pack: /team page, Person schema, 23 positioning, 8 expert_take (#1249)

Adds a broad SEO and GEO upgrade landing three surfaces at once:

- /team page with Florent Tapponnier as Maintainer, sameAs GitHub +
  LinkedIn, Person JSON-LD wired into Organization.founder and into
  every bench StatisticalReport.contributor + TechArticle.author +
  reviewedBy so YMYL crypto queries see a named human accountable.
- 23 positioning fields on alternatives specs (200 words each,
  factual, sourced from official docs, fact-checked via WebFetch).
  Cuts the shared-template surface from ~65 percent to ~30 percent
  across pages that reference the same benchmark.
- 8 expert_take fields on answers specs (150-200 words each,
  structural commentary that stays valid regardless of who currently
  leads the leaderboard) with QAPage schema so the acceptedAnswer
  (Org, auto) and suggestedAnswer (Person, expert_take) both reach
  Google + AI Overviews.
- Byline on answers pages when expert_take is present, linking to
  /team, hidden when dataPending so editorial context never renders
  next to an empty leaderboard.
- noindex on awaiting benches so Prom outages do not surface thin
  placeholder pages to crawlers.
- Optional Zod fields positioning + expert_take + expert_take_reviewed
  with noAiDashes refine so future drafts stay in policy.
- Sitemap prebuild script emits real git-commit timestamps for the
  8 editorial pages so lastmod stops resetting to build time on every
  unrelated Vercel deploy.
- scripts/editorial-check.sh lints the two new fields for em/en
  dashes, marketing adjectives, pronouns, year references.

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

* alternatives: render positioning + clean paragraph line-wrap (#1250)

Two fixes on top of #1249:

1. Add a Positioning block on /alternatives/[slug]/page.tsx that
   renders the newly-added field. Phase 1 added the Zod schema
   field but never wired the UI, so the content sat in YAMLs
   invisible on staging.

2. Rewrite the 23 positioning + 8 expert_take YAML blocks as a
   single line per paragraph. Previous version hard-wrapped at
   180 chars, which YAML literal (|) preserves as newlines,
   splitting words mid-token in the rendered HTML.

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

* hotfix: strip glued expert_take_reviewed from status line (#1251)

* alternatives: render positioning + clean paragraph line-wrap

Two fixes on top of #1249:

1. Add a Positioning block on /alternatives/[slug]/page.tsx that
   renders the newly-added field. Phase 1 added the Zod schema
   field but never wired the UI, so the content sat in YAMLs
   invisible on staging.

2. Rewrite the 23 positioning + 8 expert_take YAML blocks as a
   single line per paragraph. Previous version hard-wrapped at
   180 chars, which YAML literal (|) preserves as newlines,
   splitting words mid-token in the rendered HTML.

* fix: strip glued expert_take_reviewed from status line

The previous rewrite regex dropped the newline before existing
expert_take_reviewed values without adding a proper break, so
'status: live' and 'expert_take_reviewed: ...' ended up on the
same line, failing YAML parse and killing the build export step.

Also collapsed duplicate expert_take_reviewed lines that landed
during the double-run of the rewrite script.

---------

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

* hotfix: strip glued status line in 8 answer YAMLs (#1252)

Fix that landed in a prior branch was lost during a squash-merge
sequence. Reapplying directly off dev to guarantee it lands.

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

* answers: drop visible byline on expert_take blocks (#1253)

The visible "By Florent Tapponnier, reviewed <date>" byline
implied authorship of every expert_take paragraph and dated the
review with a fabricated timestamp, both editorially fragile.

Keep the E-E-A-T signal at the schema layer only: Person JSON-LD
on Organization.founder, StatisticalReport.contributor, TechArticle
author + reviewedBy, and QAPage suggestedAnswer.author. Search
engines see the human attribution where it counts; readers see the
editorial commentary without a personal claim on every crypto
opinion the site publishes. Removes the unused Byline component too.

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

* drop bench 080 tokenized-stock-arb-latency (Rob Chain pools too illiquid) (#1254)

5 of 11 tickers had frozen pool prices (no swap activity). Remaining 6
had p99 pinned at the 32min histogram cap because arbs rarely close
within the settle window. Robinhood Chain launched Mar 2026, tokenized
equity market still too young for a meaningful arb-latency bench.

Removed:
  * benchmarks/tokenized-stock-arb-latency.yml
  * harnesses/tokenized-stock-peg/cmd/script/arb_latency.go
  * arb_tracker calls + closeStaleOnStateChange in main.go

Kept: bench 077 tokenized-stock-peg + 079 weekend-drift (static peg
measurements) stay live on prod as the honest signal for this cluster.
Slug in REMOVED_BENCH_SLUGS for 410 Gone. Cache keys bumped.

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

* jsonld: add 'name' to TechArticle nodes for Google Rich Results validation (#1257)

GSC flagged 'Missing field name' on aggregator-head-lag JSON-LD (and by extension every bench slug page + every per-chain page + the methodology hub — all TechArticle nodes shipped only 'headline'). Schema.org Article inherits 'name' from CreativeWork; Google's Rich Results validator treats it as required even when 'headline' is present. Add 'name' alongside 'headline' with the same value on all three affected builders.

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

* share-card: propagate region/kind/venue filters + compose dynamic title (#1260)

Feedback from Maxime: exported images do not adapt to the active
filters on the bench page. A share-card exported from
/benchmarks/rpc-capabilities?chain=ethereum&region=sgp used to render
just 'Fastest free public RPC ...' with cross-chain data; now the
loader picks up the region filter and the title reads 'Fastest free
public RPC ... Ethereum · Singapore' with the correctly scoped data.

Change:
  * share-card route reads region, kind, venue URL params (symmetric
    with the existing chain param). Each maps to a spec dimension and
    forwards to the materialize loader so the data is filtered too,
    not just the title.
  * A composed contextParts string is built from the resolved dimension
    labels and appended to the aggregate title. Empty when no filter
    is active, no trailing separator on the default view.
  * The composed title is overlaid onto the benchmark passed to each
    render (spread with {...b, title: displayTitle}) so all 5 templates
    (ranking, leaderboard, snapshot, headline, compare) pick it up
    without any render-signature changes.
  * share-section + share-section-modal read live URL params for
    region/kind/venue in addition to chain when composing the card URL,
    so a modal opened from a scoped bench page passes the same scope
    to the exported PNG.

Follow-up (not in this PR): view / range / topN selectors, per-bench
UI in the modal to expose the dimension pickers explicitly. This PR
is the plumbing; the modal UX for exposing new pickers is next.

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

* Revert "share-card: propagate region/kind/venue filters + compose dynamic title (#1260)" (#1261)

This reverts commit b0ce3ef564fe4ca2aca905245c0d4dd1c6373054.

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

* Revert "Revert "share-card: propagate region/kind/venue filters + compose dynamic title (#1260)" (#1261)" (#1263)

This reverts commit d7f0fcbbbb6ad0db967c285fd23f99ca76b066f5.

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

* aggregator-head-lag: peer-activity clause (on/chain,region) fixes pool-silent false positives (#1265)

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

* share-card: filter negative p50 in sortByP50 (fixes HTTP 500 on RPC benches) (#1266)

Commit c03c599 relaxed the sortByP50 filter from `p50 > 0` to `p50 !== 0` to support the negative-p50 'warming up' sentinel introduced for funding benches on the /products page. The relaxation leaked into the share-card render path, where negatives crash Satori: negative p50 values fed to Math.max produce a negative maxP50, which inverts every bar height ratio and blows up the JSX with an opaque 'Spread syntax' error mid-render.

Symptoms observed: /benchmarks/{rpc-capabilities,polkadot-rpc,ws-head-latency-ethereum,evm-block-builders}/share-card?template=ranking all returning HTTP 500. Working benches (network-coverage, perp-fees, oracle-deviation) had no negative sentinel values in their data.

Fix restores `p50 > 0` in the share-card filter. The negative sentinel handling stays on the /products page where it was designed to render (as 'warming up'). Share-card treats negatives as noise and drops them, same as zeros.

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

* share-modal: explicit dimension pickers (chain / region / kind / venue) (#1267)

Feedback from Maxime: user needs to be able to pick chain/region/kind/venue from within the share modal, not just inherit whatever the URL had when opening. Also the composed card title must reflect the pick in real-time.

Adds a DimensionRow chip component rendered above the template tabs. One row per dimension the bench declares (chainDims / regionDims / kindDims / venueDims). Each row: 'Label' + 'All' chip + one chip per option. Clicking a chip toggles state; clicking the active pick clears back to All.

State is seeded from the live URL on mount so the modal opened from a scoped bench page starts on the same scope (unchanged behaviour). User can override in-modal by clicking a different chip; state updates re-render the card preview with the new filter forwarded to the loader.

The cardSrc URL builder now reads from picker state instead of the live URL - previous implementation re-read the URL every render which meant the modal's own state changes couldn't propagate. Same URL param names on the share-card route: no server-side change needed.

Bench without dimensions: block hidden entirely, modal looks unchanged.

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

* aggregator-head-lag: ws_connected guard filters out harness-reconnect false positives (#1269)

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

* hotfix(share-card): revert region/kind/venue passthrough + title overlay (#1270)

The region/kind/venue param plumbing (added via #1260 → reverted via
#1261 → re-added via #1263) and the display-title overlay via
{...b, title: displayTitle} still cause HTTP 500 on rpc-capabilities
share-card and other benches with dimensions. My sortByP50 fix
(#1266) turned out not to be the actual root cause; the runtime error
persists after that fix + the modal picker PR (#1267).

Reverts the route.tsx changes only:
  - Restore original getBenchmark({chain}) call (no region/kind/venue
    forwarding)
  - Restore original switch that passes plain benchmark/filteredSafe
    to renders (no {...b, title: displayTitle} spread)
  - chainLabel still passed through (unchanged from pre-Maxime-feedback)

Keeps the modal DimensionRow pickers (#1267) intact for a future
retry once the true root cause is found. They currently look for
matching URL params but the server-side won't act on them until the
route.tsx re-adds forwarding safely.

Bug still open on rpc-capabilities/polkadot-rpc/ws-head-latency-*/
evm-block-builders share-cards, but now user-visible functionality
(chain filter, existing templates on non-broken benches) is fully
restored to pre-Maxime-feedback behaviour.

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

* aggregator-head-lag: strip ws_connected clause from geckoterminal (REST-only, no ws metric) (#1272)

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

* share-card: fix HTTP 500 on RPC benches (Satori AVIF/WebP + CSS var crashes) (#1273)

Two independent bugs causing pre-existing HTTP 500 on rpc-capabilities,
polkadot-rpc, ws-head-latency-*, evm-block-builders share-card
endpoints. Reproduced locally with bun dev + shimmed try/catch to
unmask the mid-stream Satori errors.

Bug 1: Satori (the JSX-to-SVG renderer next/og uses) only decodes
PNG / JPEG / SVG. Feed it AVIF or WebP bytes and it throws
'TypeError: u2 is not iterable' mid-render, surfacing as opaque
HTTP 500. publicnode.avif, drpc.webp, lava.webp all trigger this on
the RPC benches. Fix: skip AVIF/WebP extensions in
getProviderLogoDataUrl, fall back to the initials chip.

Bug 2: chipBackground / chipTextColor from src/lib/brand.ts return
CSS var references (e.g. 'var(--color-ink-soft)') when the provider
has no registered brand color. Satori evaluates var(...) to the
CSS-wide 'initial' keyword and rejects 'background: initial',
crashing the whole PNG. evm-block-builders is a full-cohort case
(Titan, BuilderNet, Quasar, Eureka, Builder+, Vanilla — none have
brand colors or logos). Fix: resolve var(...) fallbacks to the
concrete INK_SOFT / PAPER …
Flotapponnier added a commit that referenced this pull request Jul 25, 2026
* fix(tqc): em dashes + ship to prod (#1420)

* solana cohort: solana-official logo alias, leorpc logo + light chip, registry entries

* nft harness: keyless OpenSea mode, empty x-api-key header caused 401s (#1136)

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

* nft harness: delist rarible, free tier is 100 requests per month (#1137)

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

* robinhood chain: chains hub entry, chain-kpis, l2-block-time (HTTP poll mode), network-fees

* bridge-monitor: on-chain balance fallback + degraded flag

* bridge-monitor: self-healing metrics + slack notifiers

* bridge-monitor: auto-rebalancer, corrective transfer via cheapest bridge

* bridge-monitor: tier downgrade ladder + rebalancer wiring in pre-flight

* bridge-monitor: stuck-fund reaper, hourly stranded gauge + corrective transfer

* bridge-monitor: gas auto-top-up via LiFi same-chain swap, gated by default

* bridge-monitor: unit tests for refill route, ladder, stranded hours; fix tracker reset

* bridge-monitor: keyless dry-run support for reaper + RunDryRun

* bridge-monitor: comment style cleanup in pre-flight docs

* bridge-monitor: daily spend tracker keyed by UTC date, persisted; execution mutex primitive

DailySpentUSD only ever incremented and was zeroed by every restart, so a
crash-loop minted a fresh budget each time. The tracker resets exactly at
UTC midnight, persists (date, spent) to SPEND_STATE_PATH (default
./spend-state.json, not /tmp) and is mutex-guarded. Also declares the
package-level single-flight lock and the FAILED_TX_FEE_ESTIMATE_USD
(default 0.50) helper for gas bled by failed broadcasts.

* bridge-monitor: fix double-send on in-flight rebalance, slot-wide budget, single-flight lock

Fund-loss fixes from hostile review:
- a corrective transfer whose deposit broadcast but whose bridge status
  never resolved is now TERMINAL for the scheduler slot (funds are likely
  still in flight, a retry double-sends); only pre-broadcast failures may
  consume another attempt. executeOnBridge keeps TxHash on error paths so
  callers can tell the two apart.
- ONE corrective-transfer budget (max 2) per scheduler slot, shared across
  all downgrade-ladder rungs (was per rung: up to 6 transfers per slot).
- package-level execution mutex: scheduler slots own the wallets end to
  end, the reaper TryLocks and skips its tick instead of queueing.
- broadcasts that failed or timed out after getting a TxHash book a flat
  FAILED_TX_FEE_ESTIMATE_USD against the daily cap instead of bleeding
  unaccounted gas; all spend goes through the persisted UTC-day tracker.
- gas top-up now honors MaxDailySpendUSD before broadcasting.

* bridge-monitor: SIMULATE_BALANCES only honored in dry-run mode

The reaper (and any other consumer) used to accept simulated balances even
in production with broadcast-capable keys, so phantom numbers could
green-light real transfers. SimulateBalances now takes the execution mode
and returns nil outside dry-run; startup logs a warning when the flag is
set in a broadcast-capable mode.

* tx executor: preserve hash on ambiguous send errors, split dry-run spend state

* executor: add missing chain label, first real single-test panicked post-fill (#1141)

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

* bridge-monitor: point Mobula calls at demo-api.mobula.io (#1142)

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

* tokenized-stock-peg: bench 076, onchain tokenized equities vs Nasdaq reference (staging gated)

* spec: missing store blob for a live bench throws instead of caching the miss (bnb-rpc awaiting incident) (#1144)

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

* tsp: parse the flat spark shape (close/timestamp arrays), log empty parses

* tsp: drop invalid rank_matrix_query, 1m reference candles, fee-band + USDG methodology notes

* rwa: new category, tokenized-stock-peg moves in, 11 equity logos + registry pages

* rwa: xstocks-peg (077) + usdy-nav-basis (078), issuer label on the tsp metric family

* rwa: complete product pages, hood/qqq/coin/orca/pyth logos + dual-issuer descriptions

* registry: googl links to google.com, abc.xyz 403s aggressively

* cardano: cache koios epoch params 1h (public tier was exhausted); stellar: protocol-minimum 5 reserves

* fix(store+bridge-monitor): fail-fast on missing blob + sub-50ms histogram buckets (#1152)

* spec: missing store blob for a live bench throws instead of caching the miss (bnb-rpc awaiting incident)

* bridge-monitor: sub-50ms buckets + nanosecond precision on Observe

Histogram Buckets grew from [50, 100, 200, 500, 1000, 2000, 5000, 10000]
to [10, 25, 50, 100, 200, 500, 1000, 2000, 5000, 10000]. Near Intents
on solver-cached corridors (HyperCore in particular) returns via a
1Click coordinator fast path in <50ms, and the previous scheme lumped
every one of those observations into (0, 50] so histogram_quantile
linearly interpolated to ~25ms regardless of true value. The p50 shown
on /benchmarks/bridge-quote-latency?chain=HyperCore was therefore a
bucket floor, not a measurement. Sub-50 buckets let the real bimodality
surface.

Also swap Observe(float64(quoteLatency.Milliseconds())) for
Observe(float64(quoteLatency.Nanoseconds()) / 1e6) across all 6
per-bridge observers (across, debridge, lifi, mobula, nearintents,
relay). int64 Milliseconds() truncates every sub-1ms round-trip to 0,
same bug class as the RPC harness fix in PR #1128. Not a hot path here
(network RTT dominates), but keeps precision consistent with the new
low buckets and cheap to fix while we are already touching each file.

Bench YAML methodology + FAQ updated to reflect the new bucket set.

---------

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

* fix: 3 machine-readable surface bugs (citable unit scaling, stat filters, draft asOf) (#1154)

* fix: 3 machine-readable surface bugs surfaced by GEO audit

1. /api/citable/{date} unit scaling.
   Snapshot route emitted `value: 627` for `aggregator-head-lag` while the
   live /api/citable emitted `value: 0.63` on the same measurement. The
   live route already wraps with valueInDeclaredUnit(raw, b.unit) but the
   snapshot route was left with the raw internal-ms value. Wrap the same
   way; sub-1 s benches now render in the declared `s` unit on both routes.

2. /api/stat/{slug} silently ignored ?chain= / ?region= / ?kind= / ?venue=.
   getBenchmark accepted a filters object but the route never parsed the
   URL. A citer asking /api/stat/rpc-capabilities?chain=ethereum got the
   cross-chain aggregate. Parse the four dimension params, pass through
   to the loader; unknown values fall back to unfiltered inside the
   loader. Also widen getBenchmark options type to include kind and
   venue — the underlying loadBenchmark already supports both.

3. Draft benches leaked a fake freshness signal.
   Draft placeholder set lastRunAt to new Date for type safety
   (Benchmark.lastRunAt is non-null), and both citable routes passed it
   straight through as asOf. LLM crawlers treating asOf as ground truth
   would think a draft bench was measured every minute. Null asOf in the
   JSON when status is draft; the internal type stays intact.

* fix: propagate draft-asOf null-out to /api/stat, MCP, JSON-LD dateModified

The GEO audit review found the initial fix in commit f6f84b8 only covered
the two /api/citable JSON surfaces, but the same "draft benches spoof
freshness" class of bug lives on three more machine-readable channels
that LLM crawlers also key on:

- /api/stat/{slug} asOf field.
- MCP tool responses (list_benchmarks, get_benchmark, resource template,
  and the plain-text "Last sample" line).
- JSON-LD dateModified on the bench page, per-chain sub-page, answer
  page and alternatives page — Google, Bing, Perplexity all consume
  dateModified as a freshness ranking signal.

Centralize the guard as citableAsOf(b) in @/lib/citation, rewire every
call site. Draft benches now uniformly omit or null the freshness
timestamp; live benches unchanged. On the visible /answers page,
the "Data as of..." line and its <time dateTime> anchor also hide for
drafts rather than displaying the wall-clock placeholder.

Follow-up to PR #1154 (commit f6f84b8) covering the surfaces the review
agent flagged as still spoofing.

---------

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

* feat: /api/compare/{a}/{b} + openapi.json coverage (Wave 1 P0/P1) (#1156)

* feat: /api/compare/{a}/{b} + openapi.json coverage

Two GEO surfaces flagged by the audit as high-value gaps.

## /api/compare/{a}/{b}

Head-to-head comparison as a single citable payload. LLMs asked "Mobula
vs Codex head lag" would otherwise need two /api/stat calls plus their
own delta reasoning; this endpoint returns one flat structure with per
benchmark p50 for each side, the winner on the metric's
higher-is-better convention, and the absolute delta.

The intersection logic mirrors src/lib/related-providers.ts and the
HTML /compare/{slug} page: shared benchmark = both providers appear in
it. Intentionally returns flat leader-p50 only (no chain/region
breakdowns, no cache-heavy fan out) so the agent tool call stays fast.

## openapi.json coverage

Added /api/citable/{date} (was missing despite being a real API
endpoint) and /api/compare/{a}/{b} to the OpenAPI 3.1 spec. Also
documented the dimension query params (chain/region/kind/venue) on
/api/stat/{slug} that shipped in the previous PR but were undocumented.
Added Compare schema; extended Stat schema with `filters` field.

/llms.txt and /llms-full.txt stay outside the OpenAPI surface — they
are text files not RPC endpoints, and OpenAPI consumers do not handle
plain-text specs well anyway.

## citableAsOf helper widened

Type signature relaxed from full Benchmark to
`Pick<Benchmark, "status" | "lastRunAt">` so the slim
ProviderAppearance.benchmark used by the new compare route can pass
through without a downcast. No behavior change on existing callers.

## Notes on items NOT shipped in this batch

- Cache-Control on /llms.txt / /api/citable / /api/llm-context: the
  audit report showed `cache-control: public` and inferred no CDN
  caching, but the source already sets
  `public, s-maxage=300, stale-while-revalidate=900`. Vercel strips the
  `s-maxage` directive from the client-visible header while honoring it
  at the edge; `x-vercel-cache: HIT` with `age: 256` confirms the CDN
  cache is active. Not a real bug.
- .bib / .ris file MIME endpoints: string versions already ship inside
  the /api/stat JSON via cite.bibtex / cite.apa. Marginal upside for
  Zotero-style one-click imports; downgraded to P2.
- /api/stat/{slug}/sparkline.svg: sparkline data already ships as a
  float array in /api/stat and the OG image at /api/og/{slug} covers
  the "embed a chart in an answer" case. P2.
- JSON-LD `Dataset.variableMeasured.value`: current schema emits bare
  strings; upgrading to PropertyValue objects would duplicate the
  measured value already carried by the StatisticalReport node's
  Observation.measuredValue. P2.

* openapi: add totalWins to Compare schema

---------

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

* feat: Wave 1 P2 batch — cite MIME, sparkline SVG, Dataset PropertyValue (#1158)

* feat: Wave 1 P2 batch — .bib/.ris/apa MIME, sparkline SVG, Dataset PropertyValue

Three GEO surface additions the audit downgraded to P2. Each one is
duplicative with an existing channel (cite.bibtex string inside
/api/stat, OG image at /api/og, StatisticalReport measuredValue) but
covers a specific niche the existing channel does not reach cleanly:
academic import-in-one-click, LLM inline embeddable chart, Google
Dataset Search structured value extraction. Ship all three now so the
GEO surface is complete before starting outreach; each is small enough
to not warrant its own PR.

## /api/cite/{slug}/{format}

Per-bench citation with the correct MIME type. Four formats:
  - bib -> application/x-bibtex (Zotero, LaTeX)
  - ris -> application/x-research-info-systems (EndNote, Mendeley)
  - apa -> text/plain (ready-to-paste APA string)
  - txt -> text/plain (ready-to-paste plain attribution)

Content is the same as the cite.* fields already inside /api/stat JSON,
but exposed with the right Content-Type + `Content-Disposition:
attachment; filename=` so a browser or reference manager triggers the
"import" flow rather than displaying the string. Added RIS to CiteBundle
in src/lib/citation.ts (TY GEN neutral fallback, AU / TI / PY / UR / Y2
/ ER fields, CRLF line endings per RFC convention).

## /api/sparkline/{slug}

Standalone SVG sparkline of the current leader's 24 h series. Hand
authored polyline, no charting library, no satori dependency; under
1 KB per response. Query params: `w` (40..1200, default 240), `h`
(20..400, default 60), `theme=light|dark`. Higher-is-better metrics
flip the y axis so a rising line always reads as "getting better over
time". Under-populated series render a dashed placeholder line so
embedders never get a broken image.

Cases this reaches that /api/og and the raw JSON sparkline array do
not: img/svg inline embeds in Perplexity Pages, ChatGPT answers, dev.to
posts, Notion pages. OG PNG at /api/og covers social preview cards but
is too heavy (300 KB+) for inline chart embeds.

## Dataset.variableMeasured -> PropertyValue

Bare-string array in Dataset JSON-LD upgraded to include structured
PropertyValue objects when the current leader has real numeric
aggregates. Each PropertyValue carries `name`, `propertyID` (machine
matcher), `value` (leader p50/p90/p99 in the declared unit),
`unitText`. sample_size stays a bare string — it labels an axis, not a
measurement. Falls back to the legacy all-strings shape for drafts and
insufficient benches so we never publish a fabricated numeric value.

New helper: buildBenchVariableMeasured() in src/lib/dataset-jsonld.ts.
BenchDatasetInput.variableMeasured now accepts
Array<string | VariableMeasuredValue>. `keywords` extraction pulls the
`.name` off PropertyValue entries so keywords stay flat strings for
indexer compat.

## openapi.json

Both new endpoints registered under paths, so agent frameworks
discovering the API through OpenAPI can call them.

* sparkline: XML-escape bench title + provider name in SVG

---------

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

* bridge-monitor: Mobula EIP-712 signature-confirm flow (fixes stuck deposits) (#1160)

* bridge-monitor: implement Mobula EIP-712 signature-confirm flow (fix stuck deposits)

* mobula sig: inject EIP712Domain type definition, API omits it

---------

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

* robinhood chain vague 1: weekend drift (079) + arb latency (080) + subsidy tracker (081)

* subsidy is a chain KPI, not a bench: kill standalone spec, add 3 cards to /chains/robinhood strip

* pm-api-latency: add Predexon (bench 6th provider), fresh key deployed on the 3 Railway probe regions

* usdy: 3-retry on Orca RPC to fix 40% success rate (#1168)

publicnode.com burst-limits the VPS on getAccountInfo, dropping
~60% of samples. Two retries with 500ms backoff bring hit rate to
>95% without switching endpoints. Also add commitment=confirmed
to reduce slot-miss null returns.

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

* bench vague 2: ship 081-085 (ws latency, oracle freshness, rpc reliability, indexer latency, block builders) (#1170)

* add bench 081 ws-head-latency: WS block-push lag harness + spec

* add bench 084 indexer-latency: HyperSync vs The Graph vs Alchemy time-to-queryable

* idx-latency: only classify error when every poll errored, else timeout

* bump bench-set cache keys for 084 (lockstep rule)

* add bench 082 oracle-freshness: staleness metrics in oracle-deviation harness (multi-chain chainlink, pyth publish_time fix, redstone), bump bench-set cache keys

* oracle-freshness: count unsupported mainnet feeds in freshness error counter

* add bench 085 evm-block-builders: ETH builder share via extraData + relay bidtraces + Arb/Base soft-conf lag, bump bench-set cache keys

* ebb: don't count historical relay payloads on first poll, retry arb calibration on zero support, reset WS backoff after stable session

* add bench 083 rpc-reliability: consensus lag, hash quorum, fixed-vector integrity in rpc-capabilities harness, bump bench-set cache keys

* bench 083: clamp_min rank_matrix_query so zero-incident providers keep cell ranks

* gate benches 081-085 behind staging until 48h clean data

* drop shipped RWA slugs from prod gate (live on main since #1162/#1167)

---------

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

* hyperliquid frontend page: add ProviderLogo in the hero next to the name

* hl frontends: add slash, topdog, markets-mobile logos + registry entries; dark-mode chip for slash + markets-mobile

* idx-latency: swap Alchemy for Mobula in the cohort (paid-plan key, bench races $0 pipelines only) (#1176)

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

* fix(bench): sample-size PromQL + leader reliability floor (#1175)

* fix(bench): sample-size PromQL + leader reliability floor

Two bench-correctness fixes surfaced by an audit of the oracle and
gas surfaces we plan to cite in outreach.

Bench 025 (oracle-deviation):
- sample_size was 'count(count by (source) (ocb_oracle_price))'
  which returns the count of distinct sources (3 or 4), not the
  count of samples over the window. The citable API therefore
  reported sampleSize=3-4 on every pair, at odds with the spec's
  'polled every 30s' claim. Rewrites the query to
  'sum(count_over_time(ocb_oracle_price[24h]))' across all 10
  pair entries so the number reflects actual poll volume.

Leader ranking:
- citationCandidates() now excludes providers with a measured
  success rate below 50 percent from the leader pool. Before this
  fix, gas-estimation showed Owlracle as #1 with successRate=6.48
  and a p50 gap of 0.001 gwei: technically accurate on the rare
  successful poll but useless as a leader claim. Falls back to the
  full live pool when every provider is under the floor so a
  totally degraded bench still surfaces a best-of-bad-options
  leader rather than vanishing. Freshness benches without a
  success query default to 100 percent in the loader so the guard
  is inert there.

Adds src/lib/citation.test.ts with 5 tests covering the Owlracle
scenario, higher-is-better ranking, and the degraded-pool
fallback.

* fix(bench): propagate reliability floor to rankings + template + hub

Follow-up on the previous commit. Review flagged that filtering the
leader without filtering the ranking arrays leaves self-contradictory
JSON: /api/stat can report leader=Etherscan while rankings[0]=Owlracle
on the same document.

Exports citationCandidates and adds rankedCandidates helper that
returns the same eligible pool sorted by the bench's higher_is_better
direction. leader() and fieldValue() now share it.

Wires the shared helper into every surface that emits a rankings-like
list or a best-of claim:

- src/app/api/stat/[slug]: rankings array now sorted from
  rankedCandidates so rankings[0].slug always equals leader.slug on
  the same JSON.
- src/app/api/llm-context: the Rankings numbered list under each
  bench matches the Headline sentence above it.
- src/app/api/mcp/[transport]: get_benchmark tool response and the
  openchainbench://benchmark/{slug} resource both share the pool.
- src/lib/bench-template: {{best_name}} / {{best_p50}} / {{worst_name}}
  tokens in bench copy resolve against the same pool so a bench body
  never crowns a provider its own headline excludes. Per-slug tokens
  {{p50:some-slug}} still resolve against the raw live pool so unknown
  tokens still fall through.
- src/lib/providers: rankProviders (drives /products hub wins count
  and per-chain leadership chips) now runs on the reliable pool.

Adds a consistency test locking that rankedCandidates[0].slug always
matches leader().slug on the same bench. Total: 6 tests in
citation.test.ts, 95/95 pass across src/.

Uses (r.successRate ?? 100) for the numeric guard to future-proof
against ProviderResult shapes that skip the field. Load path still
defaults to 100 (materialize/load.ts:748) so this is inert today, but
the extra ?? removes the fragility.

/api/compare/[a]/[b] is intentionally left unfiltered: the user
picks the two providers explicitly, so the head-to-head is a
requested comparison rather than a leader claim. Applying the floor
there would drop rows the user explicitly asked to see.

* fix(hub-card): honor citation leader in the hub grid headline

Follow-up review flagged that benchmark-card.tsx re-sorted b.results
locally and picked results[0], so a bench where citation demotes the
raw-p50 winner (e.g. Owlracle on gas-estimation) would show the
demoted provider on the hub grid while the bench page names the
correct leader. Two documents contradicting each other in one click.

Adds leaderSlug: string | null to BenchmarkCardData and populates it
at the projection boundary via leader(b), which already applies the
reliability + insufficient-sample filter. The card picks the leader
by matching that slug in the projected results, and falls back to the
raw best when the projection returned no leader (draft, insufficient,
or all providers filtered out) so a card still renders a headline
value in every state.

Kept the local sort so the fallback path and any downstream chip
using sorted[0].name keeps working.

Test suite unchanged: 95/95 pass across src/. No test needed since
this is a projection-boundary wire-up.

---------

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

* fix(registry): remove duplicate topdog entry (#1177)

Commit 349b786 (hl frontends: add slash, topdog, markets-mobile logos +
registry entries) added a 'topdog' entry at line 313 without noticing a
pre-existing 'topdog' entry at line 1061, breaking staging build with a
duplicate object literal type error.

Keeps the newer entry (Telegram bot at t.me/topdog_trade_bot, HL
frontend context) since it was the one intentionally added for the
Hyperliquid frontends leaderboard chip work. Drops the older Discord
signal bot entry (topdog.gg): the bench spec at
benchmarks/hyperliquid-frontends.yml has slug: topdog with name TopDog
and matches the newer, HL-native product.

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

* vague2 QA: unit sec for true-seconds benches (fixes 0.0s heros), soften 080 findings, drop dead 085 builder rows, 083 expected_n 40 (#1178)

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

* add bench 086 polkadot-rpc: first Substrate chain in the RPC cluster (#1179)

Extends rpc-capabilities to Polkadot, the first non-EVM, non-Solana
chain in the cluster. Substrate JSON-RPC via chain_getHeader (returns
hex-encoded block number matching EVM's eth_getBlockByNumber shape),
staleness by relay-block gap (40 blocks, ~4 min at Polkadot's ~6 s
cadence).

Harness changes:
- Chain.Kind gains "polkadot" alongside "solana" and default (EVM).
- New callSubstrateHeader: chain_getHeader POST, parses hex block
  number, returns parentHash as the observed hash. Same latency
  measurement + result classification as the EVM path.
- probeOne switches on Kind (was if solana / else). polkadot uses
  polkadotStaleBlockGap = 40. Consensus observation is skipped
  because chain_getHeader returns parentHash (N-1), not the current
  block hash (deriving current hash needs Blake2 over the SCALE-
  encoded header, out of scope).
- Archive loop is skipped for polkadot the same way as solana:
  Polkadot state accesses via state_getStorage keyed by Blake2 storage
  keys, no chain-agnostic depth analog for eth_getBalance-by-depth.

Providers at launch (3, live-verified on 2026-07-15 with four
consecutive chain_getHeader probes each):
- polkadot-official (Parity, rpc.polkadot.io)
- onfinality (public gateway, Web3 Foundation grantee)
- publicnode (Allnodes, extends its multi-chain footprint to
  Substrate keyless)

Audited and excluded from the launch cohort:
- 1RPC: filters chain_getHeader as "Not Allowed" on the privacy
  relay. Revisit if they expose a Substrate-friendly method set.
- Dwellir: 503 Service Unavailable during the audit sweep, revisit
  once the gateway stabilises.
- Ankr: paid Polkadot tier only.
- Chainstack: key-gated on Substrate.
- RadiumBlock: rejects without referral header.
- Grove / Thirdweb: invalid-chain response.

Spec (benchmarks/polkadot-rpc.yml, bench № 086):
- Same schema as moonbeam-rpc / avalanche-rpc etc. so the parent
  rpc-capabilities benchmark, chain cluster page and hub card
  render it identically.
- Methodology documents the Substrate specifics: probe method
  choice, staleness scaling, why no archive-depth or hash-quorum
  columns.
- SEO/finding tokens use the standard {{best_name}} / {{best_p50}}
  / {{p50:slug}} template surface, consistent with other -rpc
  benches.
- Provider tag copy explains OnFinality and Dwellir's Web3
  Foundation grantee status so the leaderboard reads correctly
  for someone who knows the Polkadot infra landscape.

Local checks:
- go build ./... clean.
- go test ./... clean.
- bun test src/ - 95/95 pass across 10 files, including the
  citation reliability floor and consistency tests added in #1175.

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

* product pages: 9 block-builder registry entries + verified logos, oracle logos (pyth/redstone) (#1181)

* products: registry entries + logos for bench 085 block builders

* oracle logos: redstone official logo + pyth/redstone manifest + registry entries

---------

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

* 085: add bobthebuilder provider row (real share, was metrics-only) (#1182)

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

* polkadot-rpc bench 086 + /chains/polkadot + Parity product (#1184)

* add bench 086 polkadot-rpc: first Substrate chain in the RPC cluster

Extends rpc-capabilities to Polkadot, the first non-EVM, non-Solana
chain in the cluster. Substrate JSON-RPC via chain_getHeader (returns
hex-encoded block number matching EVM's eth_getBlockByNumber shape),
staleness by relay-block gap (40 blocks, ~4 min at Polkadot's ~6 s
cadence).

Harness changes:
- Chain.Kind gains "polkadot" alongside "solana" and default (EVM).
- New callSubstrateHeader: chain_getHeader POST, parses hex block
  number, returns parentHash as the observed hash. Same latency
  measurement + result classification as the EVM path.
- probeOne switches on Kind (was if solana / else). polkadot uses
  polkadotStaleBlockGap = 40. Consensus observation is skipped
  because chain_getHeader returns parentHash (N-1), not the current
  block hash (deriving current hash needs Blake2 over the SCALE-
  encoded header, out of scope).
- Archive loop is skipped for polkadot the same way as solana:
  Polkadot state accesses via state_getStorage keyed by Blake2 storage
  keys, no chain-agnostic depth analog for eth_getBalance-by-depth.

Providers at launch (3, live-verified on 2026-07-15 with four
consecutive chain_getHeader probes each):
- polkadot-official (Parity, rpc.polkadot.io)
- onfinality (public gateway, Web3 Foundation grantee)
- publicnode (Allnodes, extends its multi-chain footprint to
  Substrate keyless)

Audited and excluded from the launch cohort:
- 1RPC: filters chain_getHeader as "Not Allowed" on the privacy
  relay. Revisit if they expose a Substrate-friendly method set.
- Dwellir: 503 Service Unavailable during the audit sweep, revisit
  once the gateway stabilises.
- Ankr: paid Polkadot tier only.
- Chainstack: key-gated on Substrate.
- RadiumBlock: rejects without referral header.
- Grove / Thirdweb: invalid-chain response.

Spec (benchmarks/polkadot-rpc.yml, bench № 086):
- Same schema as moonbeam-rpc / avalanche-rpc etc. so the parent
  rpc-capabilities benchmark, chain cluster page and hub card
  render it identically.
- Methodology documents the Substrate specifics: probe method
  choice, staleness scaling, why no archive-depth or hash-quorum
  columns.
- SEO/finding tokens use the standard {{best_name}} / {{best_p50}}
  / {{p50:slug}} template surface, consistent with other -rpc
  benches.
- Provider tag copy explains OnFinality and Dwellir's Web3
  Foundation grantee status so the leaderboard reads correctly
  for someone who knows the Polkadot infra landscape.

Local checks:
- go build ./... clean.
- go test ./... clean.
- bun test src/ - 95/95 pass across 10 files, including the
  citation reliability floor and consistency tests added in #1175.

* polkadot page + parity/onfinality/dwellir products

Wires the /chains/polkadot hub page, provider profiles, and logo
assets that surface once bench 086 polkadot-rpc starts feeding Prom.

- chains.ts: add Polkadot to CHAINS registry (L1, nativeSymbol DOT).
  Auto-populates /chains/polkadot once benches referencing chain="polkadot"
  land. Adaptive KPI strip fills in DefiLlama TVL, DEX volume and stables
  when the chain-kpis harness starts sourcing Polkadot alongside the
  other L1s.
- provider-registry.ts: three new entries for the Polkadot cohort.
    * parity: brand entry for Parity Technologies (Polkadot / Substrate
      implementation shop, operator of rpc.polkadot.io).
    * polkadot-official: endpoint-level entry describing the reference
      rpc.polkadot.io keyless endpoint; providers.ts aliases the slug
      to parity so the /products page collapses under the operator's
      brand.
    * dwellir: brand entry for Dwellir (Web3 Foundation grantee, keyless
      Substrate infra) — excluded from the 086 launch cohort due to a
      503 during the audit sweep but kept in the registry for the
      revisit and for other places their brand might appear.
- providers.ts: add polkadot-official → parity alias and canonical
  brand casings for polkadot / parity / onfinality / dwellir so the
  /products hub, badges, and OG images render the correct name
  without the title-case fallback butchering them.
- logo-manifest.ts: polkadot.png (chain hero), parity.png (product page
  logo), plus polkadot-official → parity slug alias so the endpoint
  chip on the bench page picks up Parity's mark automatically.

95/95 pass on bun test src/. Local build clean.

---------

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

* fix: dedup onfinality key in logo-manifest (already declared line 49) (#1186)

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

* store: last-known-good snapshot fallback (no more awaiting after first publish) (#1188)

publishSnapshot writes an lkg copy without TTL on every success;
readMaterialized falls back to it when the pointer/blob pair misses
(worker outage past blob TTL, DEL race, transient). dataAsOf keeps the
freshness badge honest about age. LKG write is best-effort and never
fails the publish.

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

* chain-kpis: add polkadot to registry (DOT native, stables via Asset Hub) (#1191)

Ships live KPIs on /chains/polkadot. DOT price and mcap flow through
Mobula. Stables card renders via DefiLlama Asset Hub aggregate.
TVL and DEX gauges stay unpublished (relay chain has zero DeFi:
parachain TVL lives under Acala, Moonbeam, Hydration).

Also guards defillama TVL publish to skip zero values so chains that
DefiLlama tracks by name but with an all zero series (Polkadot today)
hide the TVL card instead of rendering $0.

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

* live-feed status: Feed down pill + banner on aggregator-head-lag (#1193)

Short-window changes() probe per provider surfaces silent live feeds
that the 24h quantile aggregate hides (Codex WS blackout 08:23-09:04
UTC today moved the p50 by nothing but the whole feed was mute for
40min). Bench-level probe_ok gate suppresses badges when our harness
side is broken so a Railway crash cannot fake-flag every provider.

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

* series gaps: null the chart line when a feed goes silent (unless changes==0) (#1195)

Companion to feed-down banner: makes the 40min Codex blackout from
this morning (08:23-09:04 UTC) visible on the 24h chart instead of a
flat line covering the outage. Per-series match preserves the
aggregate line as long as one chain still receives events.

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

* add bench 087 ws-head-latency-base + 088 ws-head-latency-solana (#1196)

Per-chain siblings of bench 081 for the WebSocket head-lag cluster.
Same harness (harnesses/ws-head-latency), same race scoring, one
spec per chain to avoid mixing block cadences (Ethereum 12s vs Base
2s vs Solana 400ms) in a single leaderboard.

Base cohort: PublicNode + dRPC keyless endpoints. Tenderly does
not expose a keyless Base WebSocket path.

Solana cohort: PublicNode + Solana Foundation. slotSubscribe
replaces newHeads on the harness side; metric names are shared so
the queries are structurally identical to the EVM specs.

Data already flows via the Paris VPS deployment (chain label carries
base and solana). No harness change needed for this ship. Multi-region
is a follow-up on the parent bench.

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

* ws-head-latency: multi-region (harness region label + spec region tabs) (#1198)

* ws-head-latency: region label via ConstLabels + Railway config

Adds a static region ConstLabel to every ws-head-latency metric,
bound at process startup from OCB_REGION (preferred) or
RAILWAY_REPLICA_REGION (Railway auto-set), with eu-west fallback
matching the historical single-vantage Paris VPS deployment.

No observation site needs to change: the label is baked into every
counter, gauge and histogram at registration, so all existing
WithLabelValues(provider, chain) calls stay identical.

Adds railway.toml so Railway can build the harness from Dockerfile
with a /health check, matching the rpc-capabilities service layout.

Follow-up: create the Railway ws-head-latency service with 3
replicas (us-east, eu-west, sgp) and OCB_REGION set on each, then
decommission or leave the Paris VPS container as a 4th vantage.

* ws-head-latency specs: add region dimension tabs (081 + 087 + 088)

Adds `dimensions.region` block + `rank_matrix_query` on provider,
region + per-provider `regions:` sub-arrays with region-scoped
queries on the 3 ws-head-latency specs, following the polkadot-rpc
pattern.

Depends on the harness region label change (previous commit): every
metric now carries a region ConstLabel so the region-scoped
histogram queries return values as soon as multi-region replicas
publish. Until Railway 3-replica deploy lands, only region=eu-west
has data and the other tabs render empty; the All regions tab keeps
working because the aggregate query is unscoped by region.

---------

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

* ws-head-latency: align region env var with rpc-capabilities (REGION not OCB_REGION) (#1200) (#1201)

Every other multi-region harness on Railway (rpc-capabilities, aggregator-head-lag, evm-swap-quoting, pm-rate-limits, etc.) reads REGION from env. Making ws-head-latency read REGION too keeps the Railway service config uniform across the whole harness fleet.

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

* chart: red band overlay for null-bucket downtime windows (#1203)

Companion to #1195 (series null gaps). The gaps themselves render as
line breaks but a break can read as 'the chart missed a sample'. A
soft red column behind the break spans the exact outage window and
makes the reader understand it was a real feed-side silence. Per
line + per contiguous gap range, extended by one step so 1-bucket
outages stay visible instead of collapsing to a zero-width sliver.
Hidden for excluded (legend-toggled) providers.

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

* rename ws-head-latency -> ws-head-latency-ethereum for sibling slug parity (#1204)

081 becomes ws-head-latency-ethereum so the 3 per-chain benches in the head-lag cluster share the same shape: ws-head-latency-ethereum / -base / -solana. The old ws-head-latency slug is dropped (was staging-gated via REMOVED_BENCH_SLUGS, never live on prod, no external SEO to preserve).

Ungated on prod at the same time so the new slug ships alongside the base + solana siblings that are already live. Sibling text references updated to the new URL. Cache keys bench-unfiltered and all-benchmarks bumped so cached v29/v32 entries do not keep the old slug alive.

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

* network-coverage: drop coinstats (free tier credits exhausted) (#1206)

CoinStats /wallet/blockchains returns HTTP 406 "Credits limit reached" on every fetch: the free tier quota is spent below our 6h cadence and the provider never publishes a network count. Dropping until a paid key is provisioned.

Removes the coinstats.go adapter, the CoinStatsAPIKey config field, the coinstats entry in main.go, and the coinstats provider row + prose mentions in the spec. Cohort drops 6 -> 5 providers (GeckoTerminal, Codex, Mobula, CoinPaprika, Dune via Sim API).

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

* Revert "network-coverage: drop coinstats (free tier credits exhausted) (#1206)" (#1208)

This reverts commit d295f7168da981078164119e508ca9546ed69fa2.

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

* downtime bands: provider-colored tint + name/silent label + edge markers (#1210)

Feedback on #1203: the neutral red band read as 'something happened
here' without telling the reader WHO. Tint each band in the
provider's own color, add dashed edges at the outage boundaries, and
label 'PROVIDER · SILENT' above the band when it fits (with a caret
that survives on narrow single-bucket bands). Works uniformly for
Mobula/Codex/Gecko or any future provider with a live_activity
query.

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

* downtime bands: pill labels + vertical stacking on overlap (#1211)

Screenshot review: the small text-only label above the band was still
too subtle to read as 'this provider is down', and 3 simultaneous
provider outages produced overlapping unreadable labels stacked on
the same y. Replace with high-contrast rounded pills (white text on
provider color) that stack vertically when their X-ranges overlap.
Renders as '● NAME DOWN' in bold small caps so a reader gets the
event and the provider identity at a glance.

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

* tsp-arb-latency: lower trigger 50 -> 25 bps + credit sub-poll closures (#1212)

Bench 080 published only 7 samples across 3 of 11 assets in 24h at
50 bps trigger. Two contributing causes fixed together:

1) Trigger from 50 -> 25 bps. 50 bps in 90s is a headline move on
   Nasdaq (earnings, macro prints), not a steady-state event. 25 bps
   is above normal intra-minute noise on liquid stocks and captures
   real arb-worthy moves without collapsing the semantic.

2) Sub-poll convergence credited. When the reference moves past the
   trigger AND the pool is already within band on the same tick, the
   arb closed inside one poll interval. Previously dropped silently;
   now observed at pollInterval (60s) so the fast-arb tail on liquid
   pools stops disappearing from the histogram.

Convergence threshold stays at 20 bps: the "how close is the pool to
fair" question is unchanged, only trigger sensitivity moves. Spec
text (subtitle, methodology, FAQ) updated to 25 bps throughout.

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

* downtime pill: drop the leading dot indicator, center the label (#1213)

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

* downtime pill: 'DATA MISSING' wording + skip leading gaps (#1215)

Two fixes based on 30D view review:

- Leading gaps (Prom retention shorter than the visible window,
  harness started mid-range, provider added recently) rendered as
  a chart-wide band claiming every provider was down for the
  missing period. Skip runs that started before the first observed
  sample.

- Rename 'DOWN' to 'DATA MISSING'. A silent series can be the
  provider going down, our harness losing its WebSocket, or a Prom
  scrape failure — the pill stays neutral instead of blaming the
  provider.

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

* drop bench 084 indexer-latency (harness pruned, spec deleted) (#1218)

HyperSync and The Graph need paid keys for the sustained cadence the
bench expects; Mobula alone left the leaderboard single-provider and
the spec never made it out of REMOVED_BENCH_SLUGS gating. Simpler to
drop the whole cluster than to buy 2 SaaS subscriptions to prop up
a bench nobody has cited yet.

Removed:
  * benchmarks/indexer-latency.yml
  * harnesses/indexer-latency/ (whole directory)

Kept in REMOVED_BENCH_SLUGS as 410-gone so any indexed URL returns
Gone rather than 404. Cache keys bumped so cached v30/v33 entries
that still enumerate the slug age out on next revalidate.

VPS follow-up (separate ops): stop ocb-indexer-latency container,
remove from docker-compose.yml + prometheus.yml scrape config.

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

* video/render: strict Zod body schema before upstream forward (#1216)

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

* mcp: allowlist perp_funding_*, perp_venue_*, perp_execution_* prefixes (#1220)

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

* aggregator-head-lag: drop 'unless changes==0' from series queries (#1221)

Bug: the guard was designed for modern 15s-cadence data, where
changes([15m]) works cleanly. On backfilled historical data compacted
to ~1 sample/hour, changes([15m]) returns 0 even though the gauge is
healthy — the 30D view lost the first 8 days of data to false-drops
(all three providers marked DATA MISSING for a whole week).

Real-time silence is still surfaced by the live_activity scalar
(pill + banner + downtime band), which runs against fresh scrape
cadence and stays accurate. The chart series now shows every point
Prom has, flat during old outages instead of gapped.

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

* seo: 200 placeholders + sitemap threshold buffer (#1222)

- badge/[slug]/[provider]: serve pending SVG (200) instead of 404 when a provider is missing ranking data
- hyperliquid/[slug]: render lightweight placeholder page (200) instead of 307 to /hyperliquid when history blob is missing the builder
- compare adHocPairs: bump inclusion threshold to 3 (brand) / 4 (other) so a single bench flap can't cross both the sitemap gate and the page noindex gate

Fixes 3 recurring Ahrefs issues (broken badge images, noindex-in-sitemap on compare, 3xx-in-sitemap on hyperliquid) all rooted in the sitemap/page ISR race.

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

* aggregator-head-lag: restore outage detection with backfill-safe guard (#1224)

Follow-up to #1221. Dropping the guard entirely restored the 30D
range but silently hid today's Codex morning blackout on the 24H
view (frozen gauge value = flat line, indistinguishable from a
healthy quiet period).

Bring the guard back, gated on count_over_time > 5 samples in the
window. Modern data at 15s cadence carries ~60 samples per 15 min
so the gate fires the moment a WS goes silent. Backfilled data
compacted to ~1 sample/hour has count <= 2 in a 15m window so the
gate stays open and every point Prom kept is served — no more fake
wall-to-wall gap at the start of the 30D range.

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

* ws-head-latency: sub-ms histogram floor + keyed slots for base/solana (#1225)

Fix the leader-collapses-to-2.5ms artifact: histogram_quantile on a
[0,5ms] first bucket interpolates every winner's p50 to ~2.5ms
(chain-independent). Replace with a 0.5ms floor climbing to 10s so
sub-ms wins actually resolve on Solana (~400ms slots) and Base (2s).

Add keyed provider slots for Base (Alchemy/Infura/Chainstack/QuickNode)
and Solana (Alchemy/Chainstack/Helius/QuickNode) so the cohort can
extend beyond keyless once contributor keys are wired. Keyless tier
is saturated on all three chains (audit failed: Ankr, Blast, LlamaRPC,
BlockPI, 1RPC, NodeReal, Omniatech, base.org, extrnode, chainstack-
public — none accepted a keyless WSS handshake as of 2026-07).

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

* ship: ungate bench 085 evm-block-builders on prod (#1227)

Titan leads with sample=6973 in 24h, data healthy since the harness
ship. Removing from REMOVED_BENCH_SLUGS so the slug renders on prod
next revalidate. Cache keys bumped to bench-unfiltered-v32 +
all-benchmarks-v35 so cached entries do not keep dropping the slug
via the old prod gate.

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

* loader: propagate 24h series null-runs to 7d/30d (#1229)

Fine-grained outages (24h step = 20 min buckets) rarely align with
coarser evaluation timestamps (7d step = 2h, 30d step = 12h) so the
Prom query returns a healthy average and the reader sees the pill on
24H but nothing on 7D/30D. Post-process: for every contiguous null
run detected on s24, translate the time-ago range to bucket indices
on s7 and s30 and null every coarse bucket the run straddles.

Contiguity preserved so a single short outage doesn't split into two
adjacent pills after propagation. Same treatment applied to per-region
series.

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

* propagateNullsToCoarser: use absolute time-ago instead of window fraction (#1230)

Bug: the previous math scaled by the fine window fraction, which
projected a 7h-ago outage down to 40h-ago on the 7d grid (the pill
landed at -1.7d instead of -6h). Both grids share 'now' as their
right anchor and only the step sizes differ — take the fine idx's
absolute time-ago and divide by the coarse step to find the
covering bucket.

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

* tsp-arb-latency: drop 5 illiquid tickers (TSLA GOOGL AMZN MSFT SPY) (#1231)

Their Uniswap v4 pools on Robinhood Chain have no arb activity: pool spot stays frozen at the last swap value tick after tick while the Yahoo reference moves normally. Events open on ref triggers but roll over as still_open with zero latency samples. Cohort restricted to the 6 tickers with observable arb activity: NVDA AAPL PLTR META AMD MU.

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

* perp-fees harness: fix Paradex depth-skip, gains SOL slot, dYdX walk order, GMX Subsquid error surfacing (#1233)

- Paradex: cap fill ratio at 90% of visible book (depth=100). Tiers that eat through the tail now return book_too_thin and skip publishing instead of inflated 150-500 bps ghost values.
- gains: only cache USD-quoted pairs during pair discovery, widen scan 60->200. Root cause was a SOL/BTC pair overwriting the SOL/USD cache entry, publishing the wrong feeIndex and uniform 4.333 bps across ETH/BTC/SOL.
- dydx: sort v4-indexer level responses (asks ascending, bids descending) before walking the book. Insertion-order walk produced 1.7-2x inflated headline at 100k/1M tiers.
- gmx: prefix Subsquid errors, surface GraphQL errors[], fall back to unsuffixed positionFeeFactor if the impact-branch field is absent, and error out on zero factor. Prevents the silent-6bps-constant fallback path.

Verified: go build + go vet clean.

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

* downtime bands: opt-in via live_activity, skip on other benches (#1235)

Screenshot review: rpc-reliability rendered a 'LAVANODIES DATA MISSING'
pill even though its spec never declared live_activity — natural
nulls in its series24h (sparse scrapes, backfill edges) triggered
the DowntimeBands render. Gate the render on
benchmark.results.some(r => r.liveStatus != null), which is only set
when the spec declared live_activity. Same gate on
propagateNullsToCoarser so other benches' 7d/30d series stay
untouched.

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

* batch 2: MEV chips + rpc copy + Aster/edgeX + Cardano/Sui/Cosmos/Avax (#1236)

* site: provider_notes chip on ranking rows + rpc-capabilities copy fixes

- validator-yield now surfaces a per-provider chip (MEV inc./MEV excl./no MEV) beside each row so scan-readers can't accidentally compare Solana MEV-inclusive APR against Ethereum consensus-only APR without seeing the caveat
- New optional spec field provider_notes: {slug: string} plumbed end-to-end (Zod schema, TS type, materialize loader, spec overlay, ledger-table render)
- Chip renders only when the note is set, no visual change to any other bench
- rpc-capabilities: '10 EVM chains' copy corrected to 26 chains (24 EVM + Solana + Polkadot) across title/subtitle/seo_intro/abstract/methodology + FAQ
- rpc-capabilities disclaimer expanded to call out that the 'All chains' aggregate rewards single-chain foundation endpoints and to document Cloudflare-eth exclusion (returns -32603 on POST)

* perp-fees harness: add Aster + edgeX venues

- Aster (BSC): Binance-style depth walk against fapi.asterdex.com, hardcoded 5 bps taker (no public fee schedule API)
- edgeX (offchain CLOB): getDepth REST + hardcoded 3.8 bps taker per gitbook docs. Asset→contractId map declared inline (verify on first prod run; wrong ID surfaces as an orderbook fetch error, not a silent 0)
- Both venues probed for ETH/BTC/SOL at all 3 notional tiers ($1k/$100k/$1M) via applyBookTiers
- Closes the biggest coverage gap flagged by the perp-fees GEO audit (Aster is #2 perp DEX by 30d volume)

* validator-yield harness: add Cardano, Sui, Cosmos Hub, Avalanche

Closes the 'Highest staking yield' coverage gap: the bench previously ranked only Solana, Ethereum and Hyperliquid but AI answers to that question almost always include Cardano ($14B staked) and other top chains.

- Cardano (Koios API): top-50 pools by stake, net = ρ×(1-τ)×(1-margin). Simplified vs full a0/k/saturation formula but lands within ~50-100 bps of pooltool.io.
- Sui (public RPC suix_getValidatorsApy + suix_getLatestSuiSystemState): all active validators, apy is already net of commission per Sui docs.
- Cosmos Hub (LCD REST): top-200 bonded validators, net = inflation×(1-communityTax)/bondedRatio×(1-commission).
- Avalanche (P-Chain platform.getCurrentValidators): top-100 by stake, base 8.5% APR × uptime × (1 - delegationFee/100).

Reuses existing ocb_validator_* gauges from metrics.go (no new metrics, no go.mod changes). Env-overridable endpoints declared inline in each fetcher matching solana.go style. Shortcuts (uptime=100% for Cardano/Sui/Cosmos, hardcoded community_tax=2%, base 8.5% for Avalanche) documented in code.

---------

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

* rpc-capabilities: add 4 keyless providers (ThirdWeb Blast Gateway.fm bloXroute) (#1238)

Broadens the keyless cohort for benches 010 (rpc-capabilities), 083
(rpc-reliability) and the per-chain RPC benches. All 4 providers
verified live returning valid eth_getBlockByNumber on their
respective chains before inclusion.

New (provider, chain) cells:
  ThirdWeb    8 chains (ETH POL ARB OP BASE BSC AVAX LINEA)
  Blast API   4 chains (ETH ARB BASE BSC) — POL OP AVAX LINEA are 403 paid tier
  bloXroute   3 chains (ETH BASE BSC)
  Gateway.fm  1 chain  (ETH)

Total = 16 new cells. Metrics auto-flow into bench 083 (rank_matrix
groups by provider+chain) and the per-chain leaderboards after the
Railway rebuild picks up main.

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

* ws-head-latency: add Onfinality Ethereum to keyless cohort (#1241)

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

* drop oracle-freshness (082) spec + sync REMOVED with main state (#1245)

Oracle-freshness deleted from the site: apples vs oranges (Chainlink push vs Pyth/RedStone pull measure different things), splitting into pull/push sub-benches would leave each with 1-2 providers, redundant with bench 025 oracle-deviation which measures the same feeds via CEX deviation with a more actionable signal.

Slug kept in REMOVED_BENCH_SLUGS as 410 Gone. Harness code in oracle-deviation retained (metrics still emit but no site reader).

Also syncs dev's REMOVED_BENCH_SLUGS with main: removes tokenized-stock-arb-latency + rpc-reliability (shipped to prod earlier today) so dev matches main state.

Cache keys bumped bench-unfiltered-v34 + all-benchmarks-v37.

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

* SEO/E-E-A-T pack: /team page, Person schema, 23 positioning, 8 expert_take (#1249)

Adds a broad SEO and GEO upgrade landing three surfaces at once:

- /team page with Florent Tapponnier as Maintainer, sameAs GitHub +
  LinkedIn, Person JSON-LD wired into Organization.founder and into
  every bench StatisticalReport.contributor + TechArticle.author +
  reviewedBy so YMYL crypto queries see a named human accountable.
- 23 positioning fields on alternatives specs (200 words each,
  factual, sourced from official docs, fact-checked via WebFetch).
  Cuts the shared-template surface from ~65 percent to ~30 percent
  across pages that reference the same benchmark.
- 8 expert_take fields on answers specs (150-200 words each,
  structural commentary that stays valid regardless of who currently
  leads the leaderboard) with QAPage schema so the acceptedAnswer
  (Org, auto) and suggestedAnswer (Person, expert_take) both reach
  Google + AI Overviews.
- Byline on answers pages when expert_take is present, linking to
  /team, hidden when dataPending so editorial context never renders
  next to an empty leaderboard.
- noindex on awaiting benches so Prom outages do not surface thin
  placeholder pages to crawlers.
- Optional Zod fields positioning + expert_take + expert_take_reviewed
  with noAiDashes refine so future drafts stay in policy.
- Sitemap prebuild script emits real git-commit timestamps for the
  8 editorial pages so lastmod stops resetting to build time on every
  unrelated Vercel deploy.
- scripts/editorial-check.sh lints the two new fields for em/en
  dashes, marketing adjectives, pronouns, year references.

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

* alternatives: render positioning + clean paragraph line-wrap (#1250)

Two fixes on top of #1249:

1. Add a Positioning block on /alternatives/[slug]/page.tsx that
   renders the newly-added field. Phase 1 added the Zod schema
   field but never wired the UI, so the content sat in YAMLs
   invisible on staging.

2. Rewrite the 23 positioning + 8 expert_take YAML blocks as a
   single line per paragraph. Previous version hard-wrapped at
   180 chars, which YAML literal (|) preserves as newlines,
   splitting words mid-token in the rendered HTML.

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

* hotfix: strip glued expert_take_reviewed from status line (#1251)

* alternatives: render positioning + clean paragraph line-wrap

Two fixes on top of #1249:

1. Add a Positioning block on /alternatives/[slug]/page.tsx that
   renders the newly-added field. Phase 1 added the Zod schema
   field but never wired the UI, so the content sat in YAMLs
   invisible on staging.

2. Rewrite the 23 positioning + 8 expert_take YAML blocks as a
   single line per paragraph. Previous version hard-wrapped at
   180 chars, which YAML literal (|) preserves as newlines,
   splitting words mid-token in the rendered HTML.

* fix: strip glued expert_take_reviewed from status line

The previous rewrite regex dropped the newline before existing
expert_take_reviewed values without adding a proper break, so
'status: live' and 'expert_take_reviewed: ...' ended up on the
same line, failing YAML parse and killing the build export step.

Also collapsed duplicate expert_take_reviewed lines that landed
during the double-run of the rewrite script.

---------

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

* hotfix: strip glued status line in 8 answer YAMLs (#1252)

Fix that landed in a prior branch was lost during a squash-merge
sequence. Reapplying directly off dev to guarantee it lands.

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

* answers: drop visible byline on expert_take blocks (#1253)

The visible "By Florent Tapponnier, reviewed <date>" byline
implied authorship of every expert_take paragraph and dated the
review with a fabricated timestamp, both editorially fragile.

Keep the E-E-A-T signal at the schema layer only: Person JSON-LD
on Organization.founder, StatisticalReport.contributor, TechArticle
author + reviewedBy, and QAPage suggestedAnswer.author. Search
engines see the human attribution where it counts; readers see the
editorial commentary without a personal claim on every crypto
opinion the site publishes. Removes the unused Byline component too.

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

* drop bench 080 tokenized-stock-arb-latency (Rob Chain pools too illiquid) (#1254)

5 of 11 tickers had frozen pool prices (no swap activity). Remaining 6
had p99 pinned at the 32min histogram cap because arbs rarely close
within the settle window. Robinhood Chain launched Mar 2026, tokenized
equity market still too young for a meaningful arb-latency bench.

Removed:
  * benchmarks/tokenized-stock-arb-latency.yml
  * harnesses/tokenized-stock-peg/cmd/script/arb_latency.go
  * arb_tracker calls + closeStaleOnStateChange in main.go

Kept: bench 077 tokenized-stock-peg + 079 weekend-drift (static peg
measurements) stay live on prod as the honest signal for this cluster.
Slug in REMOVED_BENCH_SLUGS for 410 Gone. Cache keys bumped.

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

* jsonld: add 'name' to TechArticle nodes for Google Rich Results validation (#1257)

GSC flagged 'Missing field name' on aggregator-head-lag JSON-LD (and by extension every bench slug page + every per-chain page + the methodology hub — all TechArticle nodes shipped only 'headline'). Schema.org Article inherits 'name' from CreativeWork; Google's Rich Results validator treats it as required even when 'headline' is present. Add 'name' alongside 'headline' with the same value on all three affected builders.

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

* share-card: propagate region/kind/venue filters + compose dynamic title (#1260)

Feedback from Maxime: exported images do not adapt to the active
filters on the bench page. A share-card exported from
/benchmarks/rpc-capabilities?chain=ethereum&region=sgp used to render
just 'Fastest free public RPC ...' with cross-chain data; now the
loader picks up the region filter and the title reads 'Fastest free
public RPC ... Ethereum · Singapore' with the correctly scoped data.

Change:
  * share-card route reads region, kind, venue URL params (symmetric
    with the existing chain param). Each maps to a spec dimension and
    forwards to the materialize loader so the data is filtered too,
    not just the title.
  * A composed contextParts string is built from the resolved dimension
    labels and appended to the aggregate title. Empty when no filter
    is active, no trailing separator on the default view.
  * The composed title is overlaid onto the benchmark passed to each
    render (spread with {...b, title: displayTitle}) so all 5 templates
    (ranking, leaderboard, snapshot, headline, compare) pick it up
    without any render-signature changes.
  * share-section + share-section-modal read live URL params for
    region/kind/venue in addition to chain when composing the card URL,
    so a modal opened from a scoped bench page passes the same scope
    to the exported PNG.

Follow-up (not in this PR): view / range / topN selectors, per-bench
UI in the modal to expose the dimension pickers explicitly. This PR
is the plumbing; the modal UX for exposing new pickers is next.

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

* Revert "share-card: propagate region/kind/venue filters + compose dynamic title (#1260)" (#1261)

This reverts commit b0ce3ef564fe4ca2aca905245c0d4dd1c6373054.

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

* Revert "Revert "share-card: propagate region/kind/venue filters + compose dynamic title (#1260)" (#1261)" (#1263)

This reverts commit d7f0fcbbbb6ad0db967c285fd23f99ca76b066f5.

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

* aggregator-head-lag: peer-activity clause (on/chain,region) fixes pool-silent false positives (#1265)

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

* share-card: filter negative p50 in sortByP50 (fixes HTTP 500 on RPC benches) (#1266)

Commit c03c599 relaxed the sortByP50 filter from `p50 > 0` to `p50 !== 0` to support the negative-p50 'warming up' sentinel introduced for funding benches on the /products page. The relaxation leaked into the share-card render path, where negatives crash Satori: negative p50 values fed to Math.max produce a negative maxP50, which inverts every bar height ratio and blows up the JSX with an opaque 'Spread syntax' error mid-render.

Symptoms observed: /benchmarks/{rpc-capabilities,polkadot-rpc,ws-head-latency-ethereum,evm-block-builders}/share-card?template=ranking all returning HTTP 500. Working benches (network-coverage, perp-fees, oracle-deviation) had no negative sentinel values in their data.

Fix restores `p50 > 0` in the share-card filter. The negative sentinel handling stays on the /products page where it was designed to render (as 'warming up'). Share-card treats negatives as noise and drops them, same as zeros.

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

* share-modal: explicit dimension pickers (chain / region / kind / venue) (#1267)

Feedback from Maxime: user needs to be able to pick chain/region/kind/venue from within the share modal, not just inherit whatever the URL had when opening. Also the composed card title must reflect the pick in real-time.

Adds a DimensionRow chip component rendered above the template tabs. One row per dimension the bench declares (chainDims / regionDims / kindDims / venueDims). Each row: 'Label' + 'All' chip + one chip per option. Clicking a chip toggles state; clicking the active pick clears back to All.

State is seeded from the live URL on mount so the modal opened from a scoped bench page starts on the same scope (unchanged behaviour). User can override in-modal by clicking a different chip; state updates re-render the card preview with the new filter forwarded to the loader.

The cardSrc URL builder now reads from picker state instead of the live URL - previous implementation re-read the URL every render which meant the modal's own state changes couldn't propagate. Same URL param names on the share-card route: no server-side change needed.

Bench without dimensions: block hidden entirely, modal looks unchanged.

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

* aggregator-head-lag: ws_connected guard filters out harness-reconnect false positives (#1269)

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

* hotfix(share-card): revert region/kind/venue passthrough + title overlay (#1270)

The region/kind/venue param plumbing (added via #1260 → reverted via
#1261 → re-added via #1263) and the display-title overlay via
{...b, title: displayTitle} still cause HTTP 500 on rpc-capabilities
share-card and other benches with dimensions. My sortByP50 fix
(#1266) turned out not to be the actual root cause; the runtime error
persists after that fix + the modal picker PR (#1267).

Reverts the route.tsx changes only:
  - Restore original getBenchmark({chain}) call (no region/kind/venue
    forwarding)
  - Restore original switch that passes plain benchmark/filteredSafe
    to renders (no {...b, title: displayTitle} spread)
  - chainLabel still passed through (unchanged from pre-Maxime-feedback)

Keeps the modal DimensionRow pickers (#1267) intact for a future
retry once the true root cause is found. They currently look for
matching URL params but the server-side won't act on them until the
route.tsx re-adds forwarding safely.

Bug still open on rpc-capabilities/polkadot-rpc/ws-head-latency-*/
evm-block-builders share-cards, but now user-visible functionality
(chain filter, existing templates on non-broken benches) is fully
restored to pre-Maxime-feedback behaviour.

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

* aggregator-head-lag: strip ws_connected clause from geckoterminal (REST-only, no ws metric) (#1272)

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

* share-card: fix HTTP 500 on RPC benches (Satori AVIF/WebP + CSS var crashes) (#1273)

Two independent bugs causing pre-existing HTTP 500 on rpc-capabilities,
polkadot-rpc, ws-head-latency-*, evm-block-builders share-card
endpoints. Reproduced locally with bun dev + shimmed try/catch to
unmask the mid-stream Satori errors.

Bug 1: Satori (the JSX-to-SVG renderer next/og uses) only decodes
PNG / JPEG / SVG. Feed it AVIF or WebP bytes and it throws
'TypeError: u2 is not iterable' mid-render, surfacing as opaque
HTTP 500. publicnode.avif, drpc.webp, lava.webp all trigger this on
the RPC benches. Fix: skip AVIF/WebP extensions in
getProviderLogoDataUrl, fall back to the initials chip.

Bug 2: chipBackground / chipTextColor from src/lib/brand.ts return
CSS var references (e.g. 'var(--color-ink-soft)') when the provider
has no registered brand color. Satori evaluates var(...) to the
CSS-wide 'initial' keyword and rejects 'background: initial',
crashing the whole PNG. evm-block-builders is a full-cohort case
(Titan, BuilderNet, Quasar, Eureka, Builder+, Vanilla — none have
brand colors o…
Flotapponnier added a commit that referenced this pull request Jul 25, 2026
* solana cohort: solana-official logo alias, leorpc logo + light chip, registry entries

* nft harness: keyless OpenSea mode, empty x-api-key header caused 401s (#1136)

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

* nft harness: delist rarible, free tier is 100 requests per month (#1137)

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

* robinhood chain: chains hub entry, chain-kpis, l2-block-time (HTTP poll mode), network-fees

* bridge-monitor: on-chain balance fallback + degraded flag

* bridge-monitor: self-healing metrics + slack notifiers

* bridge-monitor: auto-rebalancer, corrective transfer via cheapest bridge

* bridge-monitor: tier downgrade ladder + rebalancer wiring in pre-flight

* bridge-monitor: stuck-fund reaper, hourly stranded gauge + corrective transfer

* bridge-monitor: gas auto-top-up via LiFi same-chain swap, gated by default

* bridge-monitor: unit tests for refill route, ladder, stranded hours; fix tracker reset

* bridge-monitor: keyless dry-run support for reaper + RunDryRun

* bridge-monitor: comment style cleanup in pre-flight docs

* bridge-monitor: daily spend tracker keyed by UTC date, persisted; execution mutex primitive

DailySpentUSD only ever incremented and was zeroed by every restart, so a
crash-loop minted a fresh budget each time. The tracker resets exactly at
UTC midnight, persists (date, spent) to SPEND_STATE_PATH (default
./spend-state.json, not /tmp) and is mutex-guarded. Also declares the
package-level single-flight lock and the FAILED_TX_FEE_ESTIMATE_USD
(default 0.50) helper for gas bled by failed broadcasts.

* bridge-monitor: fix double-send on in-flight rebalance, slot-wide budget, single-flight lock

Fund-loss fixes from hostile review:
- a corrective transfer whose deposit broadcast but whose bridge status
  never resolved is now TERMINAL for the scheduler slot (funds are likely
  still in flight, a retry double-sends); only pre-broadcast failures may
  consume another attempt. executeOnBridge keeps TxHash on error paths so
  callers can tell the two apart.
- ONE corrective-transfer budget (max 2) per scheduler slot, shared across
  all downgrade-ladder rungs (was per rung: up to 6 transfers per slot).
- package-level execution mutex: scheduler slots own the wallets end to
  end, the reaper TryLocks and skips its tick instead of queueing.
- broadcasts that failed or timed out after getting a TxHash book a flat
  FAILED_TX_FEE_ESTIMATE_USD against the daily cap instead of bleeding
  unaccounted gas; all spend goes through the persisted UTC-day tracker.
- gas top-up now honors MaxDailySpendUSD before broadcasting.

* bridge-monitor: SIMULATE_BALANCES only honored in dry-run mode

The reaper (and any other consumer) used to accept simulated balances even
in production with broadcast-capable keys, so phantom numbers could
green-light real transfers. SimulateBalances now takes the execution mode
and returns nil outside dry-run; startup logs a warning when the flag is
set in a broadcast-capable mode.

* tx executor: preserve hash on ambiguous send errors, split dry-run spend state

* executor: add missing chain label, first real single-test panicked post-fill (#1141)

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

* bridge-monitor: point Mobula calls at demo-api.mobula.io (#1142)

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

* tokenized-stock-peg: bench 076, onchain tokenized equities vs Nasdaq reference (staging gated)

* spec: missing store blob for a live bench throws instead of caching the miss (bnb-rpc awaiting incident) (#1144)

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

* tsp: parse the flat spark shape (close/timestamp arrays), log empty parses

* tsp: drop invalid rank_matrix_query, 1m reference candles, fee-band + USDG methodology notes

* rwa: new category, tokenized-stock-peg moves in, 11 equity logos + registry pages

* rwa: xstocks-peg (077) + usdy-nav-basis (078), issuer label on the tsp metric family

* rwa: complete product pages, hood/qqq/coin/orca/pyth logos + dual-issuer descriptions

* registry: googl links to google.com, abc.xyz 403s aggressively

* cardano: cache koios epoch params 1h (public tier was exhausted); stellar: protocol-minimum 5 reserves

* fix(store+bridge-monitor): fail-fast on missing blob + sub-50ms histogram buckets (#1152)

* spec: missing store blob for a live bench throws instead of caching the miss (bnb-rpc awaiting incident)

* bridge-monitor: sub-50ms buckets + nanosecond precision on Observe

Histogram Buckets grew from [50, 100, 200, 500, 1000, 2000, 5000, 10000]
to [10, 25, 50, 100, 200, 500, 1000, 2000, 5000, 10000]. Near Intents
on solver-cached corridors (HyperCore in particular) returns via a
1Click coordinator fast path in <50ms, and the previous scheme lumped
every one of those observations into (0, 50] so histogram_quantile
linearly interpolated to ~25ms regardless of true value. The p50 shown
on /benchmarks/bridge-quote-latency?chain=HyperCore was therefore a
bucket floor, not a measurement. Sub-50 buckets let the real bimodality
surface.

Also swap Observe(float64(quoteLatency.Milliseconds())) for
Observe(float64(quoteLatency.Nanoseconds()) / 1e6) across all 6
per-bridge observers (across, debridge, lifi, mobula, nearintents,
relay). int64 Milliseconds() truncates every sub-1ms round-trip to 0,
same bug class as the RPC harness fix in PR #1128. Not a hot path here
(network RTT dominates), but keeps precision consistent with the new
low buckets and cheap to fix while we are already touching each file.

Bench YAML methodology + FAQ updated to reflect the new bucket set.

---------

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

* fix: 3 machine-readable surface bugs (citable unit scaling, stat filters, draft asOf) (#1154)

* fix: 3 machine-readable surface bugs surfaced by GEO audit

1. /api/citable/{date} unit scaling.
   Snapshot route emitted `value: 627` for `aggregator-head-lag` while the
   live /api/citable emitted `value: 0.63` on the same measurement. The
   live route already wraps with valueInDeclaredUnit(raw, b.unit) but the
   snapshot route was left with the raw internal-ms value. Wrap the same
   way; sub-1 s benches now render in the declared `s` unit on both routes.

2. /api/stat/{slug} silently ignored ?chain= / ?region= / ?kind= / ?venue=.
   getBenchmark accepted a filters object but the route never parsed the
   URL. A citer asking /api/stat/rpc-capabilities?chain=ethereum got the
   cross-chain aggregate. Parse the four dimension params, pass through
   to the loader; unknown values fall back to unfiltered inside the
   loader. Also widen getBenchmark options type to include kind and
   venue — the underlying loadBenchmark already supports both.

3. Draft benches leaked a fake freshness signal.
   Draft placeholder set lastRunAt to new Date for type safety
   (Benchmark.lastRunAt is non-null), and both citable routes passed it
   straight through as asOf. LLM crawlers treating asOf as ground truth
   would think a draft bench was measured every minute. Null asOf in the
   JSON when status is draft; the internal type stays intact.

* fix: propagate draft-asOf null-out to /api/stat, MCP, JSON-LD dateModified

The GEO audit review found the initial fix in commit f6f84b8 only covered
the two /api/citable JSON surfaces, but the same "draft benches spoof
freshness" class of bug lives on three more machine-readable channels
that LLM crawlers also key on:

- /api/stat/{slug} asOf field.
- MCP tool responses (list_benchmarks, get_benchmark, resource template,
  and the plain-text "Last sample" line).
- JSON-LD dateModified on the bench page, per-chain sub-page, answer
  page and alternatives page — Google, Bing, Perplexity all consume
  dateModified as a freshness ranking signal.

Centralize the guard as citableAsOf(b) in @/lib/citation, rewire every
call site. Draft benches now uniformly omit or null the freshness
timestamp; live benches unchanged. On the visible /answers page,
the "Data as of..." line and its <time dateTime> anchor also hide for
drafts rather than displaying the wall-clock placeholder.

Follow-up to PR #1154 (commit f6f84b8) covering the surfaces the review
agent flagged as still spoofing.

---------

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

* feat: /api/compare/{a}/{b} + openapi.json coverage (Wave 1 P0/P1) (#1156)

* feat: /api/compare/{a}/{b} + openapi.json coverage

Two GEO surfaces flagged by the audit as high-value gaps.

## /api/compare/{a}/{b}

Head-to-head comparison as a single citable payload. LLMs asked "Mobula
vs Codex head lag" would otherwise need two /api/stat calls plus their
own delta reasoning; this endpoint returns one flat structure with per
benchmark p50 for each side, the winner on the metric's
higher-is-better convention, and the absolute delta.

The intersection logic mirrors src/lib/related-providers.ts and the
HTML /compare/{slug} page: shared benchmark = both providers appear in
it. Intentionally returns flat leader-p50 only (no chain/region
breakdowns, no cache-heavy fan out) so the agent tool call stays fast.

## openapi.json coverage

Added /api/citable/{date} (was missing despite being a real API
endpoint) and /api/compare/{a}/{b} to the OpenAPI 3.1 spec. Also
documented the dimension query params (chain/region/kind/venue) on
/api/stat/{slug} that shipped in the previous PR but were undocumented.
Added Compare schema; extended Stat schema with `filters` field.

/llms.txt and /llms-full.txt stay outside the OpenAPI surface — they
are text files not RPC endpoints, and OpenAPI consumers do not handle
plain-text specs well anyway.

## citableAsOf helper widened

Type signature relaxed from full Benchmark to
`Pick<Benchmark, "status" | "lastRunAt">` so the slim
ProviderAppearance.benchmark used by the new compare route can pass
through without a downcast. No behavior change on existing callers.

## Notes on items NOT shipped in this batch

- Cache-Control on /llms.txt / /api/citable / /api/llm-context: the
  audit report showed `cache-control: public` and inferred no CDN
  caching, but the source already sets
  `public, s-maxage=300, stale-while-revalidate=900`. Vercel strips the
  `s-maxage` directive from the client-visible header while honoring it
  at the edge; `x-vercel-cache: HIT` with `age: 256` confirms the CDN
  cache is active. Not a real bug.
- .bib / .ris file MIME endpoints: string versions already ship inside
  the /api/stat JSON via cite.bibtex / cite.apa. Marginal upside for
  Zotero-style one-click imports; downgraded to P2.
- /api/stat/{slug}/sparkline.svg: sparkline data already ships as a
  float array in /api/stat and the OG image at /api/og/{slug} covers
  the "embed a chart in an answer" case. P2.
- JSON-LD `Dataset.variableMeasured.value`: current schema emits bare
  strings; upgrading to PropertyValue objects would duplicate the
  measured value already carried by the StatisticalReport node's
  Observation.measuredValue. P2.

* openapi: add totalWins to Compare schema

---------

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

* feat: Wave 1 P2 batch — cite MIME, sparkline SVG, Dataset PropertyValue (#1158)

* feat: Wave 1 P2 batch — .bib/.ris/apa MIME, sparkline SVG, Dataset PropertyValue

Three GEO surface additions the audit downgraded to P2. Each one is
duplicative with an existing channel (cite.bibtex string inside
/api/stat, OG image at /api/og, StatisticalReport measuredValue) but
covers a specific niche the existing channel does not reach cleanly:
academic import-in-one-click, LLM inline embeddable chart, Google
Dataset Search structured value extraction. Ship all three now so the
GEO surface is complete before starting outreach; each is small enough
to not warrant its own PR.

## /api/cite/{slug}/{format}

Per-bench citation with the correct MIME type. Four formats:
  - bib -> application/x-bibtex (Zotero, LaTeX)
  - ris -> application/x-research-info-systems (EndNote, Mendeley)
  - apa -> text/plain (ready-to-paste APA string)
  - txt -> text/plain (ready-to-paste plain attribution)

Content is the same as the cite.* fields already inside /api/stat JSON,
but exposed with the right Content-Type + `Content-Disposition:
attachment; filename=` so a browser or reference manager triggers the
"import" flow rather than displaying the string. Added RIS to CiteBundle
in src/lib/citation.ts (TY GEN neutral fallback, AU / TI / PY / UR / Y2
/ ER fields, CRLF line endings per RFC convention).

## /api/sparkline/{slug}

Standalone SVG sparkline of the current leader's 24 h series. Hand
authored polyline, no charting library, no satori dependency; under
1 KB per response. Query params: `w` (40..1200, default 240), `h`
(20..400, default 60), `theme=light|dark`. Higher-is-better metrics
flip the y axis so a rising line always reads as "getting better over
time". Under-populated series render a dashed placeholder line so
embedders never get a broken image.

Cases this reaches that /api/og and the raw JSON sparkline array do
not: img/svg inline embeds in Perplexity Pages, ChatGPT answers, dev.to
posts, Notion pages. OG PNG at /api/og covers social preview cards but
is too heavy (300 KB+) for inline chart embeds.

## Dataset.variableMeasured -> PropertyValue

Bare-string array in Dataset JSON-LD upgraded to include structured
PropertyValue objects when the current leader has real numeric
aggregates. Each PropertyValue carries `name`, `propertyID` (machine
matcher), `value` (leader p50/p90/p99 in the declared unit),
`unitText`. sample_size stays a bare string — it labels an axis, not a
measurement. Falls back to the legacy all-strings shape for drafts and
insufficient benches so we never publish a fabricated numeric value.

New helper: buildBenchVariableMeasured() in src/lib/dataset-jsonld.ts.
BenchDatasetInput.variableMeasured now accepts
Array<string | VariableMeasuredValue>. `keywords` extraction pulls the
`.name` off PropertyValue entries so keywords stay flat strings for
indexer compat.

## openapi.json

Both new endpoints registered under paths, so agent frameworks
discovering the API through OpenAPI can call them.

* sparkline: XML-escape bench title + provider name in SVG

---------

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

* bridge-monitor: Mobula EIP-712 signature-confirm flow (fixes stuck deposits) (#1160)

* bridge-monitor: implement Mobula EIP-712 signature-confirm flow (fix stuck deposits)

* mobula sig: inject EIP712Domain type definition, API omits it

---------

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

* robinhood chain vague 1: weekend drift (079) + arb latency (080) + subsidy tracker (081)

* subsidy is a chain KPI, not a bench: kill standalone spec, add 3 cards to /chains/robinhood strip

* pm-api-latency: add Predexon (bench 6th provider), fresh key deployed on the 3 Railway probe regions

* usdy: 3-retry on Orca RPC to fix 40% success rate (#1168)

publicnode.com burst-limits the VPS on getAccountInfo, dropping
~60% of samples. Two retries with 500ms backoff bring hit rate to
>95% without switching endpoints. Also add commitment=confirmed
to reduce slot-miss null returns.

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

* bench vague 2: ship 081-085 (ws latency, oracle freshness, rpc reliability, indexer latency, block builders) (#1170)

* add bench 081 ws-head-latency: WS block-push lag harness + spec

* add bench 084 indexer-latency: HyperSync vs The Graph vs Alchemy time-to-queryable

* idx-latency: only classify error when every poll errored, else timeout

* bump bench-set cache keys for 084 (lockstep rule)

* add bench 082 oracle-freshness: staleness metrics in oracle-deviation harness (multi-chain chainlink, pyth publish_time fix, redstone), bump bench-set cache keys

* oracle-freshness: count unsupported mainnet feeds in freshness error counter

* add bench 085 evm-block-builders: ETH builder share via extraData + relay bidtraces + Arb/Base soft-conf lag, bump bench-set cache keys

* ebb: don't count historical relay payloads on first poll, retry arb calibration on zero support, reset WS backoff after stable session

* add bench 083 rpc-reliability: consensus lag, hash quorum, fixed-vector integrity in rpc-capabilities harness, bump bench-set cache keys

* bench 083: clamp_min rank_matrix_query so zero-incident providers keep cell ranks

* gate benches 081-085 behind staging until 48h clean data

* drop shipped RWA slugs from prod gate (live on main since #1162/#1167)

---------

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

* hyperliquid frontend page: add ProviderLogo in the hero next to the name

* hl frontends: add slash, topdog, markets-mobile logos + registry entries; dark-mode chip for slash + markets-mobile

* idx-latency: swap Alchemy for Mobula in the cohort (paid-plan key, bench races $0 pipelines only) (#1176)

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

* fix(bench): sample-size PromQL + leader reliability floor (#1175)

* fix(bench): sample-size PromQL + leader reliability floor

Two bench-correctness fixes surfaced by an audit of the oracle and
gas surfaces we plan to cite in outreach.

Bench 025 (oracle-deviation):
- sample_size was 'count(count by (source) (ocb_oracle_price))'
  which returns the count of distinct sources (3 or 4), not the
  count of samples over the window. The citable API therefore
  reported sampleSize=3-4 on every pair, at odds with the spec's
  'polled every 30s' claim. Rewrites the query to
  'sum(count_over_time(ocb_oracle_price[24h]))' across all 10
  pair entries so the number reflects actual poll volume.

Leader ranking:
- citationCandidates() now excludes providers with a measured
  success rate below 50 percent from the leader pool. Before this
  fix, gas-estimation showed Owlracle as #1 with successRate=6.48
  and a p50 gap of 0.001 gwei: technically accurate on the rare
  successful poll but useless as a leader claim. Falls back to the
  full live pool when every provider is under the floor so a
  totally degraded bench still surfaces a best-of-bad-options
  leader rather than vanishing. Freshness benches without a
  success query default to 100 percent in the loader so the guard
  is inert there.

Adds src/lib/citation.test.ts with 5 tests covering the Owlracle
scenario, higher-is-better ranking, and the degraded-pool
fallback.

* fix(bench): propagate reliability floor to rankings + template + hub

Follow-up on the previous commit. Review flagged that filtering the
leader without filtering the ranking arrays leaves self-contradictory
JSON: /api/stat can report leader=Etherscan while rankings[0]=Owlracle
on the same document.

Exports citationCandidates and adds rankedCandidates helper that
returns the same eligible pool sorted by the bench's higher_is_better
direction. leader() and fieldValue() now share it.

Wires the shared helper into every surface that emits a rankings-like
list or a best-of claim:

- src/app/api/stat/[slug]: rankings array now sorted from
  rankedCandidates so rankings[0].slug always equals leader.slug on
  the same JSON.
- src/app/api/llm-context: the Rankings numbered list under each
  bench matches the Headline sentence above it.
- src/app/api/mcp/[transport]: get_benchmark tool response and the
  openchainbench://benchmark/{slug} resource both share the pool.
- src/lib/bench-template: {{best_name}} / {{best_p50}} / {{worst_name}}
  tokens in bench copy resolve against the same pool so a bench body
  never crowns a provider its own headline excludes. Per-slug tokens
  {{p50:some-slug}} still resolve against the raw live pool so unknown
  tokens still fall through.
- src/lib/providers: rankProviders (drives /products hub wins count
  and per-chain leadership chips) now runs on the reliable pool.

Adds a consistency test locking that rankedCandidates[0].slug always
matches leader().slug on the same bench. Total: 6 tests in
citation.test.ts, 95/95 pass across src/.

Uses (r.successRate ?? 100) for the numeric guard to future-proof
against ProviderResult shapes that skip the field. Load path still
defaults to 100 (materialize/load.ts:748) so this is inert today, but
the extra ?? removes the fragility.

/api/compare/[a]/[b] is intentionally left unfiltered: the user
picks the two providers explicitly, so the head-to-head is a
requested comparison rather than a leader claim. Applying the floor
there would drop rows the user explicitly asked to see.

* fix(hub-card): honor citation leader in the hub grid headline

Follow-up review flagged that benchmark-card.tsx re-sorted b.results
locally and picked results[0], so a bench where citation demotes the
raw-p50 winner (e.g. Owlracle on gas-estimation) would show the
demoted provider on the hub grid while the bench page names the
correct leader. Two documents contradicting each other in one click.

Adds leaderSlug: string | null to BenchmarkCardData and populates it
at the projection boundary via leader(b), which already applies the
reliability + insufficient-sample filter. The card picks the leader
by matching that slug in the projected results, and falls back to the
raw best when the projection returned no leader (draft, insufficient,
or all providers filtered out) so a card still renders a headline
value in every state.

Kept the local sort so the fallback path and any downstream chip
using sorted[0].name keeps working.

Test suite unchanged: 95/95 pass across src/. No test needed since
this is a projection-boundary wire-up.

---------

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

* fix(registry): remove duplicate topdog entry (#1177)

Commit 349b786 (hl frontends: add slash, topdog, markets-mobile logos +
registry entries) added a 'topdog' entry at line 313 without noticing a
pre-existing 'topdog' entry at line 1061, breaking staging build with a
duplicate object literal type error.

Keeps the newer entry (Telegram bot at t.me/topdog_trade_bot, HL
frontend context) since it was the one intentionally added for the
Hyperliquid frontends leaderboard chip work. Drops the older Discord
signal bot entry (topdog.gg): the bench spec at
benchmarks/hyperliquid-frontends.yml has slug: topdog with name TopDog
and matches the newer, HL-native product.

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

* vague2 QA: unit sec for true-seconds benches (fixes 0.0s heros), soften 080 findings, drop dead 085 builder rows, 083 expected_n 40 (#1178)

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

* add bench 086 polkadot-rpc: first Substrate chain in the RPC cluster (#1179)

Extends rpc-capabilities to Polkadot, the first non-EVM, non-Solana
chain in the cluster. Substrate JSON-RPC via chain_getHeader (returns
hex-encoded block number matching EVM's eth_getBlockByNumber shape),
staleness by relay-block gap (40 blocks, ~4 min at Polkadot's ~6 s
cadence).

Harness changes:
- Chain.Kind gains "polkadot" alongside "solana" and default (EVM).
- New callSubstrateHeader: chain_getHeader POST, parses hex block
  number, returns parentHash as the observed hash. Same latency
  measurement + result classification as the EVM path.
- probeOne switches on Kind (was if solana / else). polkadot uses
  polkadotStaleBlockGap = 40. Consensus observation is skipped
  because chain_getHeader returns parentHash (N-1), not the current
  block hash (deriving current hash needs Blake2 over the SCALE-
  encoded header, out of scope).
- Archive loop is skipped for polkadot the same way as solana:
  Polkadot state accesses via state_getStorage keyed by Blake2 storage
  keys, no chain-agnostic depth analog for eth_getBalance-by-depth.

Providers at launch (3, live-verified on 2026-07-15 with four
consecutive chain_getHeader probes each):
- polkadot-official (Parity, rpc.polkadot.io)
- onfinality (public gateway, Web3 Foundation grantee)
- publicnode (Allnodes, extends its multi-chain footprint to
  Substrate keyless)

Audited and excluded from the launch cohort:
- 1RPC: filters chain_getHeader as "Not Allowed" on the privacy
  relay. Revisit if they expose a Substrate-friendly method set.
- Dwellir: 503 Service Unavailable during the audit sweep, revisit
  once the gateway stabilises.
- Ankr: paid Polkadot tier only.
- Chainstack: key-gated on Substrate.
- RadiumBlock: rejects without referral header.
- Grove / Thirdweb: invalid-chain response.

Spec (benchmarks/polkadot-rpc.yml, bench № 086):
- Same schema as moonbeam-rpc / avalanche-rpc etc. so the parent
  rpc-capabilities benchmark, chain cluster page and hub card
  render it identically.
- Methodology documents the Substrate specifics: probe method
  choice, staleness scaling, why no archive-depth or hash-quorum
  columns.
- SEO/finding tokens use the standard {{best_name}} / {{best_p50}}
  / {{p50:slug}} template surface, consistent with other -rpc
  benches.
- Provider tag copy explains OnFinality and Dwellir's Web3
  Foundation grantee status so the leaderboard reads correctly
  for someone who knows the Polkadot infra landscape.

Local checks:
- go build ./... clean.
- go test ./... clean.
- bun test src/ - 95/95 pass across 10 files, including the
  citation reliability floor and consistency tests added in #1175.

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

* product pages: 9 block-builder registry entries + verified logos, oracle logos (pyth/redstone) (#1181)

* products: registry entries + logos for bench 085 block builders

* oracle logos: redstone official logo + pyth/redstone manifest + registry entries

---------

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

* 085: add bobthebuilder provider row (real share, was metrics-only) (#1182)

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

* polkadot-rpc bench 086 + /chains/polkadot + Parity product (#1184)

* add bench 086 polkadot-rpc: first Substrate chain in the RPC cluster

Extends rpc-capabilities to Polkadot, the first non-EVM, non-Solana
chain in the cluster. Substrate JSON-RPC via chain_getHeader (returns
hex-encoded block number matching EVM's eth_getBlockByNumber shape),
staleness by relay-block gap (40 blocks, ~4 min at Polkadot's ~6 s
cadence).

Harness changes:
- Chain.Kind gains "polkadot" alongside "solana" and default (EVM).
- New callSubstrateHeader: chain_getHeader POST, parses hex block
  number, returns parentHash as the observed hash. Same latency
  measurement + result classification as the EVM path.
- probeOne switches on Kind (was if solana / else). polkadot uses
  polkadotStaleBlockGap = 40. Consensus observation is skipped
  because chain_getHeader returns parentHash (N-1), not the current
  block hash (deriving current hash needs Blake2 over the SCALE-
  encoded header, out of scope).
- Archive loop is skipped for polkadot the same way as solana:
  Polkadot state accesses via state_getStorage keyed by Blake2 storage
  keys, no chain-agnostic depth analog for eth_getBalance-by-depth.

Providers at launch (3, live-verified on 2026-07-15 with four
consecutive chain_getHeader probes each):
- polkadot-official (Parity, rpc.polkadot.io)
- onfinality (public gateway, Web3 Foundation grantee)
- publicnode (Allnodes, extends its multi-chain footprint to
  Substrate keyless)

Audited and excluded from the launch cohort:
- 1RPC: filters chain_getHeader as "Not Allowed" on the privacy
  relay. Revisit if they expose a Substrate-friendly method set.
- Dwellir: 503 Service Unavailable during the audit sweep, revisit
  once the gateway stabilises.
- Ankr: paid Polkadot tier only.
- Chainstack: key-gated on Substrate.
- RadiumBlock: rejects without referral header.
- Grove / Thirdweb: invalid-chain response.

Spec (benchmarks/polkadot-rpc.yml, bench № 086):
- Same schema as moonbeam-rpc / avalanche-rpc etc. so the parent
  rpc-capabilities benchmark, chain cluster page and hub card
  render it identically.
- Methodology documents the Substrate specifics: probe method
  choice, staleness scaling, why no archive-depth or hash-quorum
  columns.
- SEO/finding tokens use the standard {{best_name}} / {{best_p50}}
  / {{p50:slug}} template surface, consistent with other -rpc
  benches.
- Provider tag copy explains OnFinality and Dwellir's Web3
  Foundation grantee status so the leaderboard reads correctly
  for someone who knows the Polkadot infra landscape.

Local checks:
- go build ./... clean.
- go test ./... clean.
- bun test src/ - 95/95 pass across 10 files, including the
  citation reliability floor and consistency tests added in #1175.

* polkadot page + parity/onfinality/dwellir products

Wires the /chains/polkadot hub page, provider profiles, and logo
assets that surface once bench 086 polkadot-rpc starts feeding Prom.

- chains.ts: add Polkadot to CHAINS registry (L1, nativeSymbol DOT).
  Auto-populates /chains/polkadot once benches referencing chain="polkadot"
  land. Adaptive KPI strip fills in DefiLlama TVL, DEX volume and stables
  when the chain-kpis harness starts sourcing Polkadot alongside the
  other L1s.
- provider-registry.ts: three new entries for the Polkadot cohort.
    * parity: brand entry for Parity Technologies (Polkadot / Substrate
      implementation shop, operator of rpc.polkadot.io).
    * polkadot-official: endpoint-level entry describing the reference
      rpc.polkadot.io keyless endpoint; providers.ts aliases the slug
      to parity so the /products page collapses under the operator's
      brand.
    * dwellir: brand entry for Dwellir (Web3 Foundation grantee, keyless
      Substrate infra) — excluded from the 086 launch cohort due to a
      503 during the audit sweep but kept in the registry for the
      revisit and for other places their brand might appear.
- providers.ts: add polkadot-official → parity alias and canonical
  brand casings for polkadot / parity / onfinality / dwellir so the
  /products hub, badges, and OG images render the correct name
  without the title-case fallback butchering them.
- logo-manifest.ts: polkadot.png (chain hero), parity.png (product page
  logo), plus polkadot-official → parity slug alias so the endpoint
  chip on the bench page picks up Parity's mark automatically.

95/95 pass on bun test src/. Local build clean.

---------

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

* fix: dedup onfinality key in logo-manifest (already declared line 49) (#1186)

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

* store: last-known-good snapshot fallback (no more awaiting after first publish) (#1188)

publishSnapshot writes an lkg copy without TTL on every success;
readMaterialized falls back to it when the pointer/blob pair misses
(worker outage past blob TTL, DEL race, transient). dataAsOf keeps the
freshness badge honest about age. LKG write is best-effort and never
fails the publish.

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

* chain-kpis: add polkadot to registry (DOT native, stables via Asset Hub) (#1191)

Ships live KPIs on /chains/polkadot. DOT price and mcap flow through
Mobula. Stables card renders via DefiLlama Asset Hub aggregate.
TVL and DEX gauges stay unpublished (relay chain has zero DeFi:
parachain TVL lives under Acala, Moonbeam, Hydration).

Also guards defillama TVL publish to skip zero values so chains that
DefiLlama tracks by name but with an all zero series (Polkadot today)
hide the TVL card instead of rendering $0.

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

* live-feed status: Feed down pill + banner on aggregator-head-lag (#1193)

Short-window changes() probe per provider surfaces silent live feeds
that the 24h quantile aggregate hides (Codex WS blackout 08:23-09:04
UTC today moved the p50 by nothing but the whole feed was mute for
40min). Bench-level probe_ok gate suppresses badges when our harness
side is broken so a Railway crash cannot fake-flag every provider.

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

* series gaps: null the chart line when a feed goes silent (unless changes==0) (#1195)

Companion to feed-down banner: makes the 40min Codex blackout from
this morning (08:23-09:04 UTC) visible on the 24h chart instead of a
flat line covering the outage. Per-series match preserves the
aggregate line as long as one chain still receives events.

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

* add bench 087 ws-head-latency-base + 088 ws-head-latency-solana (#1196)

Per-chain siblings of bench 081 for the WebSocket head-lag cluster.
Same harness (harnesses/ws-head-latency), same race scoring, one
spec per chain to avoid mixing block cadences (Ethereum 12s vs Base
2s vs Solana 400ms) in a single leaderboard.

Base cohort: PublicNode + dRPC keyless endpoints. Tenderly does
not expose a keyless Base WebSocket path.

Solana cohort: PublicNode + Solana Foundation. slotSubscribe
replaces newHeads on the harness side; metric names are shared so
the queries are structurally identical to the EVM specs.

Data already flows via the Paris VPS deployment (chain label carries
base and solana). No harness change needed for this ship. Multi-region
is a follow-up on the parent bench.

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

* ws-head-latency: multi-region (harness region label + spec region tabs) (#1198)

* ws-head-latency: region label via ConstLabels + Railway config

Adds a static region ConstLabel to every ws-head-latency metric,
bound at process startup from OCB_REGION (preferred) or
RAILWAY_REPLICA_REGION (Railway auto-set), with eu-west fallback
matching the historical single-vantage Paris VPS deployment.

No observation site needs to change: the label is baked into every
counter, gauge and histogram at registration, so all existing
WithLabelValues(provider, chain) calls stay identical.

Adds railway.toml so Railway can build the harness from Dockerfile
with a /health check, matching the rpc-capabilities service layout.

Follow-up: create the Railway ws-head-latency service with 3
replicas (us-east, eu-west, sgp) and OCB_REGION set on each, then
decommission or leave the Paris VPS container as a 4th vantage.

* ws-head-latency specs: add region dimension tabs (081 + 087 + 088)

Adds `dimensions.region` block + `rank_matrix_query` on provider,
region + per-provider `regions:` sub-arrays with region-scoped
queries on the 3 ws-head-latency specs, following the polkadot-rpc
pattern.

Depends on the harness region label change (previous commit): every
metric now carries a region ConstLabel so the region-scoped
histogram queries return values as soon as multi-region replicas
publish. Until Railway 3-replica deploy lands, only region=eu-west
has data and the other tabs render empty; the All regions tab keeps
working because the aggregate query is unscoped by region.

---------

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

* ws-head-latency: align region env var with rpc-capabilities (REGION not OCB_REGION) (#1200) (#1201)

Every other multi-region harness on Railway (rpc-capabilities, aggregator-head-lag, evm-swap-quoting, pm-rate-limits, etc.) reads REGION from env. Making ws-head-latency read REGION too keeps the Railway service config uniform across the whole harness fleet.

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

* chart: red band overlay for null-bucket downtime windows (#1203)

Companion to #1195 (series null gaps). The gaps themselves render as
line breaks but a break can read as 'the chart missed a sample'. A
soft red column behind the break spans the exact outage window and
makes the reader understand it was a real feed-side silence. Per
line + per contiguous gap range, extended by one step so 1-bucket
outages stay visible instead of collapsing to a zero-width sliver.
Hidden for excluded (legend-toggled) providers.

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

* rename ws-head-latency -> ws-head-latency-ethereum for sibling slug parity (#1204)

081 becomes ws-head-latency-ethereum so the 3 per-chain benches in the head-lag cluster share the same shape: ws-head-latency-ethereum / -base / -solana. The old ws-head-latency slug is dropped (was staging-gated via REMOVED_BENCH_SLUGS, never live on prod, no external SEO to preserve).

Ungated on prod at the same time so the new slug ships alongside the base + solana siblings that are already live. Sibling text references updated to the new URL. Cache keys bench-unfiltered and all-benchmarks bumped so cached v29/v32 entries do not keep the old slug alive.

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

* network-coverage: drop coinstats (free tier credits exhausted) (#1206)

CoinStats /wallet/blockchains returns HTTP 406 "Credits limit reached" on every fetch: the free tier quota is spent below our 6h cadence and the provider never publishes a network count. Dropping until a paid key is provisioned.

Removes the coinstats.go adapter, the CoinStatsAPIKey config field, the coinstats entry in main.go, and the coinstats provider row + prose mentions in the spec. Cohort drops 6 -> 5 providers (GeckoTerminal, Codex, Mobula, CoinPaprika, Dune via Sim API).

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

* Revert "network-coverage: drop coinstats (free tier credits exhausted) (#1206)" (#1208)

This reverts commit d295f7168da981078164119e508ca9546ed69fa2.

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

* downtime bands: provider-colored tint + name/silent label + edge markers (#1210)

Feedback on #1203: the neutral red band read as 'something happened
here' without telling the reader WHO. Tint each band in the
provider's own color, add dashed edges at the outage boundaries, and
label 'PROVIDER · SILENT' above the band when it fits (with a caret
that survives on narrow single-bucket bands). Works uniformly for
Mobula/Codex/Gecko or any future provider with a live_activity
query.

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

* downtime bands: pill labels + vertical stacking on overlap (#1211)

Screenshot review: the small text-only label above the band was still
too subtle to read as 'this provider is down', and 3 simultaneous
provider outages produced overlapping unreadable labels stacked on
the same y. Replace with high-contrast rounded pills (white text on
provider color) that stack vertically when their X-ranges overlap.
Renders as '● NAME DOWN' in bold small caps so a reader gets the
event and the provider identity at a glance.

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

* tsp-arb-latency: lower trigger 50 -> 25 bps + credit sub-poll closures (#1212)

Bench 080 published only 7 samples across 3 of 11 assets in 24h at
50 bps trigger. Two contributing causes fixed together:

1) Trigger from 50 -> 25 bps. 50 bps in 90s is a headline move on
   Nasdaq (earnings, macro prints), not a steady-state event. 25 bps
   is above normal intra-minute noise on liquid stocks and captures
   real arb-worthy moves without collapsing the semantic.

2) Sub-poll convergence credited. When the reference moves past the
   trigger AND the pool is already within band on the same tick, the
   arb closed inside one poll interval. Previously dropped silently;
   now observed at pollInterval (60s) so the fast-arb tail on liquid
   pools stops disappearing from the histogram.

Convergence threshold stays at 20 bps: the "how close is the pool to
fair" question is unchanged, only trigger sensitivity moves. Spec
text (subtitle, methodology, FAQ) updated to 25 bps throughout.

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

* downtime pill: drop the leading dot indicator, center the label (#1213)

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

* downtime pill: 'DATA MISSING' wording + skip leading gaps (#1215)

Two fixes based on 30D view review:

- Leading gaps (Prom retention shorter than the visible window,
  harness started mid-range, provider added recently) rendered as
  a chart-wide band claiming every provider was down for the
  missing period. Skip runs that started before the first observed
  sample.

- Rename 'DOWN' to 'DATA MISSING'. A silent series can be the
  provider going down, our harness losing its WebSocket, or a Prom
  scrape failure — the pill stays neutral instead of blaming the
  provider.

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

* drop bench 084 indexer-latency (harness pruned, spec deleted) (#1218)

HyperSync and The Graph need paid keys for the sustained cadence the
bench expects; Mobula alone left the leaderboard single-provider and
the spec never made it out of REMOVED_BENCH_SLUGS gating. Simpler to
drop the whole cluster than to buy 2 SaaS subscriptions to prop up
a bench nobody has cited yet.

Removed:
  * benchmarks/indexer-latency.yml
  * harnesses/indexer-latency/ (whole directory)

Kept in REMOVED_BENCH_SLUGS as 410-gone so any indexed URL returns
Gone rather than 404. Cache keys bumped so cached v30/v33 entries
that still enumerate the slug age out on next revalidate.

VPS follow-up (separate ops): stop ocb-indexer-latency container,
remove from docker-compose.yml + prometheus.yml scrape config.

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

* video/render: strict Zod body schema before upstream forward (#1216)

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

* mcp: allowlist perp_funding_*, perp_venue_*, perp_execution_* prefixes (#1220)

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

* aggregator-head-lag: drop 'unless changes==0' from series queries (#1221)

Bug: the guard was designed for modern 15s-cadence data, where
changes([15m]) works cleanly. On backfilled historical data compacted
to ~1 sample/hour, changes([15m]) returns 0 even though the gauge is
healthy — the 30D view lost the first 8 days of data to false-drops
(all three providers marked DATA MISSING for a whole week).

Real-time silence is still surfaced by the live_activity scalar
(pill + banner + downtime band), which runs against fresh scrape
cadence and stays accurate. The chart series now shows every point
Prom has, flat during old outages instead of gapped.

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

* seo: 200 placeholders + sitemap threshold buffer (#1222)

- badge/[slug]/[provider]: serve pending SVG (200) instead of 404 when a provider is missing ranking data
- hyperliquid/[slug]: render lightweight placeholder page (200) instead of 307 to /hyperliquid when history blob is missing the builder
- compare adHocPairs: bump inclusion threshold to 3 (brand) / 4 (other) so a single bench flap can't cross both the sitemap gate and the page noindex gate

Fixes 3 recurring Ahrefs issues (broken badge images, noindex-in-sitemap on compare, 3xx-in-sitemap on hyperliquid) all rooted in the sitemap/page ISR race.

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

* aggregator-head-lag: restore outage detection with backfill-safe guard (#1224)

Follow-up to #1221. Dropping the guard entirely restored the 30D
range but silently hid today's Codex morning blackout on the 24H
view (frozen gauge value = flat line, indistinguishable from a
healthy quiet period).

Bring the guard back, gated on count_over_time > 5 samples in the
window. Modern data at 15s cadence carries ~60 samples per 15 min
so the gate fires the moment a WS goes silent. Backfilled data
compacted to ~1 sample/hour has count <= 2 in a 15m window so the
gate stays open and every point Prom kept is served — no more fake
wall-to-wall gap at the start of the 30D range.

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

* ws-head-latency: sub-ms histogram floor + keyed slots for base/solana (#1225)

Fix the leader-collapses-to-2.5ms artifact: histogram_quantile on a
[0,5ms] first bucket interpolates every winner's p50 to ~2.5ms
(chain-independent). Replace with a 0.5ms floor climbing to 10s so
sub-ms wins actually resolve on Solana (~400ms slots) and Base (2s).

Add keyed provider slots for Base (Alchemy/Infura/Chainstack/QuickNode)
and Solana (Alchemy/Chainstack/Helius/QuickNode) so the cohort can
extend beyond keyless once contributor keys are wired. Keyless tier
is saturated on all three chains (audit failed: Ankr, Blast, LlamaRPC,
BlockPI, 1RPC, NodeReal, Omniatech, base.org, extrnode, chainstack-
public — none accepted a keyless WSS handshake as of 2026-07).

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

* ship: ungate bench 085 evm-block-builders on prod (#1227)

Titan leads with sample=6973 in 24h, data healthy since the harness
ship. Removing from REMOVED_BENCH_SLUGS so the slug renders on prod
next revalidate. Cache keys bumped to bench-unfiltered-v32 +
all-benchmarks-v35 so cached entries do not keep dropping the slug
via the old prod gate.

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

* loader: propagate 24h series null-runs to 7d/30d (#1229)

Fine-grained outages (24h step = 20 min buckets) rarely align with
coarser evaluation timestamps (7d step = 2h, 30d step = 12h) so the
Prom query returns a healthy average and the reader sees the pill on
24H but nothing on 7D/30D. Post-process: for every contiguous null
run detected on s24, translate the time-ago range to bucket indices
on s7 and s30 and null every coarse bucket the run straddles.

Contiguity preserved so a single short outage doesn't split into two
adjacent pills after propagation. Same treatment applied to per-region
series.

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

* propagateNullsToCoarser: use absolute time-ago instead of window fraction (#1230)

Bug: the previous math scaled by the fine window fraction, which
projected a 7h-ago outage down to 40h-ago on the 7d grid (the pill
landed at -1.7d instead of -6h). Both grids share 'now' as their
right anchor and only the step sizes differ — take the fine idx's
absolute time-ago and divide by the coarse step to find the
covering bucket.

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

* tsp-arb-latency: drop 5 illiquid tickers (TSLA GOOGL AMZN MSFT SPY) (#1231)

Their Uniswap v4 pools on Robinhood Chain have no arb activity: pool spot stays frozen at the last swap value tick after tick while the Yahoo reference moves normally. Events open on ref triggers but roll over as still_open with zero latency samples. Cohort restricted to the 6 tickers with observable arb activity: NVDA AAPL PLTR META AMD MU.

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

* perp-fees harness: fix Paradex depth-skip, gains SOL slot, dYdX walk order, GMX Subsquid error surfacing (#1233)

- Paradex: cap fill ratio at 90% of visible book (depth=100). Tiers that eat through the tail now return book_too_thin and skip publishing instead of inflated 150-500 bps ghost values.
- gains: only cache USD-quoted pairs during pair discovery, widen scan 60->200. Root cause was a SOL/BTC pair overwriting the SOL/USD cache entry, publishing the wrong feeIndex and uniform 4.333 bps across ETH/BTC/SOL.
- dydx: sort v4-indexer level responses (asks ascending, bids descending) before walking the book. Insertion-order walk produced 1.7-2x inflated headline at 100k/1M tiers.
- gmx: prefix Subsquid errors, surface GraphQL errors[], fall back to unsuffixed positionFeeFactor if the impact-branch field is absent, and error out on zero factor. Prevents the silent-6bps-constant fallback path.

Verified: go build + go vet clean.

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

* downtime bands: opt-in via live_activity, skip on other benches (#1235)

Screenshot review: rpc-reliability rendered a 'LAVANODIES DATA MISSING'
pill even though its spec never declared live_activity — natural
nulls in its series24h (sparse scrapes, backfill edges) triggered
the DowntimeBands render. Gate the render on
benchmark.results.some(r => r.liveStatus != null), which is only set
when the spec declared live_activity. Same gate on
propagateNullsToCoarser so other benches' 7d/30d series stay
untouched.

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

* batch 2: MEV chips + rpc copy + Aster/edgeX + Cardano/Sui/Cosmos/Avax (#1236)

* site: provider_notes chip on ranking rows + rpc-capabilities copy fixes

- validator-yield now surfaces a per-provider chip (MEV inc./MEV excl./no MEV) beside each row so scan-readers can't accidentally compare Solana MEV-inclusive APR against Ethereum consensus-only APR without seeing the caveat
- New optional spec field provider_notes: {slug: string} plumbed end-to-end (Zod schema, TS type, materialize loader, spec overlay, ledger-table render)
- Chip renders only when the note is set, no visual change to any other bench
- rpc-capabilities: '10 EVM chains' copy corrected to 26 chains (24 EVM + Solana + Polkadot) across title/subtitle/seo_intro/abstract/methodology + FAQ
- rpc-capabilities disclaimer expanded to call out that the 'All chains' aggregate rewards single-chain foundation endpoints and to document Cloudflare-eth exclusion (returns -32603 on POST)

* perp-fees harness: add Aster + edgeX venues

- Aster (BSC): Binance-style depth walk against fapi.asterdex.com, hardcoded 5 bps taker (no public fee schedule API)
- edgeX (offchain CLOB): getDepth REST + hardcoded 3.8 bps taker per gitbook docs. Asset→contractId map declared inline (verify on first prod run; wrong ID surfaces as an orderbook fetch error, not a silent 0)
- Both venues probed for ETH/BTC/SOL at all 3 notional tiers ($1k/$100k/$1M) via applyBookTiers
- Closes the biggest coverage gap flagged by the perp-fees GEO audit (Aster is #2 perp DEX by 30d volume)

* validator-yield harness: add Cardano, Sui, Cosmos Hub, Avalanche

Closes the 'Highest staking yield' coverage gap: the bench previously ranked only Solana, Ethereum and Hyperliquid but AI answers to that question almost always include Cardano ($14B staked) and other top chains.

- Cardano (Koios API): top-50 pools by stake, net = ρ×(1-τ)×(1-margin). Simplified vs full a0/k/saturation formula but lands within ~50-100 bps of pooltool.io.
- Sui (public RPC suix_getValidatorsApy + suix_getLatestSuiSystemState): all active validators, apy is already net of commission per Sui docs.
- Cosmos Hub (LCD REST): top-200 bonded validators, net = inflation×(1-communityTax)/bondedRatio×(1-commission).
- Avalanche (P-Chain platform.getCurrentValidators): top-100 by stake, base 8.5% APR × uptime × (1 - delegationFee/100).

Reuses existing ocb_validator_* gauges from metrics.go (no new metrics, no go.mod changes). Env-overridable endpoints declared inline in each fetcher matching solana.go style. Shortcuts (uptime=100% for Cardano/Sui/Cosmos, hardcoded community_tax=2%, base 8.5% for Avalanche) documented in code.

---------

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

* rpc-capabilities: add 4 keyless providers (ThirdWeb Blast Gateway.fm bloXroute) (#1238)

Broadens the keyless cohort for benches 010 (rpc-capabilities), 083
(rpc-reliability) and the per-chain RPC benches. All 4 providers
verified live returning valid eth_getBlockByNumber on their
respective chains before inclusion.

New (provider, chain) cells:
  ThirdWeb    8 chains (ETH POL ARB OP BASE BSC AVAX LINEA)
  Blast API   4 chains (ETH ARB BASE BSC) — POL OP AVAX LINEA are 403 paid tier
  bloXroute   3 chains (ETH BASE BSC)
  Gateway.fm  1 chain  (ETH)

Total = 16 new cells. Metrics auto-flow into bench 083 (rank_matrix
groups by provider+chain) and the per-chain leaderboards after the
Railway rebuild picks up main.

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

* ws-head-latency: add Onfinality Ethereum to keyless cohort (#1241)

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

* drop oracle-freshness (082) spec + sync REMOVED with main state (#1245)

Oracle-freshness deleted from the site: apples vs oranges (Chainlink push vs Pyth/RedStone pull measure different things), splitting into pull/push sub-benches would leave each with 1-2 providers, redundant with bench 025 oracle-deviation which measures the same feeds via CEX deviation with a more actionable signal.

Slug kept in REMOVED_BENCH_SLUGS as 410 Gone. Harness code in oracle-deviation retained (metrics still emit but no site reader).

Also syncs dev's REMOVED_BENCH_SLUGS with main: removes tokenized-stock-arb-latency + rpc-reliability (shipped to prod earlier today) so dev matches main state.

Cache keys bumped bench-unfiltered-v34 + all-benchmarks-v37.

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

* SEO/E-E-A-T pack: /team page, Person schema, 23 positioning, 8 expert_take (#1249)

Adds a broad SEO and GEO upgrade landing three surfaces at once:

- /team page with Florent Tapponnier as Maintainer, sameAs GitHub +
  LinkedIn, Person JSON-LD wired into Organization.founder and into
  every bench StatisticalReport.contributor + TechArticle.author +
  reviewedBy so YMYL crypto queries see a named human accountable.
- 23 positioning fields on alternatives specs (200 words each,
  factual, sourced from official docs, fact-checked via WebFetch).
  Cuts the shared-template surface from ~65 percent to ~30 percent
  across pages that reference the same benchmark.
- 8 expert_take fields on answers specs (150-200 words each,
  structural commentary that stays valid regardless of who currently
  leads the leaderboard) with QAPage schema so the acceptedAnswer
  (Org, auto) and suggestedAnswer (Person, expert_take) both reach
  Google + AI Overviews.
- Byline on answers pages when expert_take is present, linking to
  /team, hidden when dataPending so editorial context never renders
  next to an empty leaderboard.
- noindex on awaiting benches so Prom outages do not surface thin
  placeholder pages to crawlers.
- Optional Zod fields positioning + expert_take + expert_take_reviewed
  with noAiDashes refine so future drafts stay in policy.
- Sitemap prebuild script emits real git-commit timestamps for the
  8 editorial pages so lastmod stops resetting to build time on every
  unrelated Vercel deploy.
- scripts/editorial-check.sh lints the two new fields for em/en
  dashes, marketing adjectives, pronouns, year references.

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

* alternatives: render positioning + clean paragraph line-wrap (#1250)

Two fixes on top of #1249:

1. Add a Positioning block on /alternatives/[slug]/page.tsx that
   renders the newly-added field. Phase 1 added the Zod schema
   field but never wired the UI, so the content sat in YAMLs
   invisible on staging.

2. Rewrite the 23 positioning + 8 expert_take YAML blocks as a
   single line per paragraph. Previous version hard-wrapped at
   180 chars, which YAML literal (|) preserves as newlines,
   splitting words mid-token in the rendered HTML.

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

* hotfix: strip glued expert_take_reviewed from status line (#1251)

* alternatives: render positioning + clean paragraph line-wrap

Two fixes on top of #1249:

1. Add a Positioning block on /alternatives/[slug]/page.tsx that
   renders the newly-added field. Phase 1 added the Zod schema
   field but never wired the UI, so the content sat in YAMLs
   invisible on staging.

2. Rewrite the 23 positioning + 8 expert_take YAML blocks as a
   single line per paragraph. Previous version hard-wrapped at
   180 chars, which YAML literal (|) preserves as newlines,
   splitting words mid-token in the rendered HTML.

* fix: strip glued expert_take_reviewed from status line

The previous rewrite regex dropped the newline before existing
expert_take_reviewed values without adding a proper break, so
'status: live' and 'expert_take_reviewed: ...' ended up on the
same line, failing YAML parse and killing the build export step.

Also collapsed duplicate expert_take_reviewed lines that landed
during the double-run of the rewrite script.

---------

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

* hotfix: strip glued status line in 8 answer YAMLs (#1252)

Fix that landed in a prior branch was lost during a squash-merge
sequence. Reapplying directly off dev to guarantee it lands.

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

* answers: drop visible byline on expert_take blocks (#1253)

The visible "By Florent Tapponnier, reviewed <date>" byline
implied authorship of every expert_take paragraph and dated the
review with a fabricated timestamp, both editorially fragile.

Keep the E-E-A-T signal at the schema layer only: Person JSON-LD
on Organization.founder, StatisticalReport.contributor, TechArticle
author + reviewedBy, and QAPage suggestedAnswer.author. Search
engines see the human attribution where it counts; readers see the
editorial commentary without a personal claim on every crypto
opinion the site publishes. Removes the unused Byline component too.

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

* drop bench 080 tokenized-stock-arb-latency (Rob Chain pools too illiquid) (#1254)

5 of 11 tickers had frozen pool prices (no swap activity). Remaining 6
had p99 pinned at the 32min histogram cap because arbs rarely close
within the settle window. Robinhood Chain launched Mar 2026, tokenized
equity market still too young for a meaningful arb-latency bench.

Removed:
  * benchmarks/tokenized-stock-arb-latency.yml
  * harnesses/tokenized-stock-peg/cmd/script/arb_latency.go
  * arb_tracker calls + closeStaleOnStateChange in main.go

Kept: bench 077 tokenized-stock-peg + 079 weekend-drift (static peg
measurements) stay live on prod as the honest signal for this cluster.
Slug in REMOVED_BENCH_SLUGS for 410 Gone. Cache keys bumped.

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

* jsonld: add 'name' to TechArticle nodes for Google Rich Results validation (#1257)

GSC flagged 'Missing field name' on aggregator-head-lag JSON-LD (and by extension every bench slug page + every per-chain page + the methodology hub — all TechArticle nodes shipped only 'headline'). Schema.org Article inherits 'name' from CreativeWork; Google's Rich Results validator treats it as required even when 'headline' is present. Add 'name' alongside 'headline' with the same value on all three affected builders.

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

* share-card: propagate region/kind/venue filters + compose dynamic title (#1260)

Feedback from Maxime: exported images do not adapt to the active
filters on the bench page. A share-card exported from
/benchmarks/rpc-capabilities?chain=ethereum&region=sgp used to render
just 'Fastest free public RPC ...' with cross-chain data; now the
loader picks up the region filter and the title reads 'Fastest free
public RPC ... Ethereum · Singapore' with the correctly scoped data.

Change:
  * share-card route reads region, kind, venue URL params (symmetric
    with the existing chain param). Each maps to a spec dimension and
    forwards to the materialize loader so the data is filtered too,
    not just the title.
  * A composed contextParts string is built from the resolved dimension
    labels and appended to the aggregate title. Empty when no filter
    is active, no trailing separator on the default view.
  * The composed title is overlaid onto the benchmark passed to each
    render (spread with {...b, title: displayTitle}) so all 5 templates
    (ranking, leaderboard, snapshot, headline, compare) pick it up
    without any render-signature changes.
  * share-section + share-section-modal read live URL params for
    region/kind/venue in addition to chain when composing the card URL,
    so a modal opened from a scoped bench page passes the same scope
    to the exported PNG.

Follow-up (not in this PR): view / range / topN selectors, per-bench
UI in the modal to expose the dimension pickers explicitly. This PR
is the plumbing; the modal UX for exposing new pickers is next.

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

* Revert "share-card: propagate region/kind/venue filters + compose dynamic title (#1260)" (#1261)

This reverts commit b0ce3ef564fe4ca2aca905245c0d4dd1c6373054.

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

* Revert "Revert "share-card: propagate region/kind/venue filters + compose dynamic title (#1260)" (#1261)" (#1263)

This reverts commit d7f0fcbbbb6ad0db967c285fd23f99ca76b066f5.

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

* aggregator-head-lag: peer-activity clause (on/chain,region) fixes pool-silent false positives (#1265)

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

* share-card: filter negative p50 in sortByP50 (fixes HTTP 500 on RPC benches) (#1266)

Commit c03c599 relaxed the sortByP50 filter from `p50 > 0` to `p50 !== 0` to support the negative-p50 'warming up' sentinel introduced for funding benches on the /products page. The relaxation leaked into the share-card render path, where negatives crash Satori: negative p50 values fed to Math.max produce a negative maxP50, which inverts every bar height ratio and blows up the JSX with an opaque 'Spread syntax' error mid-render.

Symptoms observed: /benchmarks/{rpc-capabilities,polkadot-rpc,ws-head-latency-ethereum,evm-block-builders}/share-card?template=ranking all returning HTTP 500. Working benches (network-coverage, perp-fees, oracle-deviation) had no negative sentinel values in their data.

Fix restores `p50 > 0` in the share-card filter. The negative sentinel handling stays on the /products page where it was designed to render (as 'warming up'). Share-card treats negatives as noise and drops them, same as zeros.

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

* share-modal: explicit dimension pickers (chain / region / kind / venue) (#1267)

Feedback from Maxime: user needs to be able to pick chain/region/kind/venue from within the share modal, not just inherit whatever the URL had when opening. Also the composed card title must reflect the pick in real-time.

Adds a DimensionRow chip component rendered above the template tabs. One row per dimension the bench declares (chainDims / regionDims / kindDims / venueDims). Each row: 'Label' + 'All' chip + one chip per option. Clicking a chip toggles state; clicking the active pick clears back to All.

State is seeded from the live URL on mount so the modal opened from a scoped bench page starts on the same scope (unchanged behaviour). User can override in-modal by clicking a different chip; state updates re-render the card preview with the new filter forwarded to the loader.

The cardSrc URL builder now reads from picker state instead of the live URL - previous implementation re-read the URL every render which meant the modal's own state changes couldn't propagate. Same URL param names on the share-card route: no server-side change needed.

Bench without dimensions: block hidden entirely, modal looks unchanged.

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

* aggregator-head-lag: ws_connected guard filters out harness-reconnect false positives (#1269)

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

* hotfix(share-card): revert region/kind/venue passthrough + title overlay (#1270)

The region/kind/venue param plumbing (added via #1260 → reverted via
#1261 → re-added via #1263) and the display-title overlay via
{...b, title: displayTitle} still cause HTTP 500 on rpc-capabilities
share-card and other benches with dimensions. My sortByP50 fix
(#1266) turned out not to be the actual root cause; the runtime error
persists after that fix + the modal picker PR (#1267).

Reverts the route.tsx changes only:
  - Restore original getBenchmark({chain}) call (no region/kind/venue
    forwarding)
  - Restore original switch that passes plain benchmark/filteredSafe
    to renders (no {...b, title: displayTitle} spread)
  - chainLabel still passed through (unchanged from pre-Maxime-feedback)

Keeps the modal DimensionRow pickers (#1267) intact for a future
retry once the true root cause is found. They currently look for
matching URL params but the server-side won't act on them until the
route.tsx re-adds forwarding safely.

Bug still open on rpc-capabilities/polkadot-rpc/ws-head-latency-*/
evm-block-builders share-cards, but now user-visible functionality
(chain filter, existing templates on non-broken benches) is fully
restored to pre-Maxime-feedback behaviour.

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

* aggregator-head-lag: strip ws_connected clause from geckoterminal (REST-only, no ws metric) (#1272)

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

* share-card: fix HTTP 500 on RPC benches (Satori AVIF/WebP + CSS var crashes) (#1273)

Two independent bugs causing pre-existing HTTP 500 on rpc-capabilities,
polkadot-rpc, ws-head-latency-*, evm-block-builders share-card
endpoints. Reproduced locally with bun dev + shimmed try/catch to
unmask the mid-stream Satori errors.

Bug 1: Satori (the JSX-to-SVG renderer next/og uses) only decodes
PNG / JPEG / SVG. Feed it AVIF or WebP bytes and it throws
'TypeError: u2 is not iterable' mid-render, surfacing as opaque
HTTP 500. publicnode.avif, drpc.webp, lava.webp all trigger this on
the RPC benches. Fix: skip AVIF/WebP extensions in
getProviderLogoDataUrl, fall back to the initials chip.

Bug 2: chipBackground / chipTextColor from src/lib/brand.ts return
CSS var references (e.g. 'var(--color-ink-soft)') when the provider
has no registered brand color. Satori evaluates var(...) to the
CSS-wide 'initial' keyword and rejects 'background: initial',
crashing the whole PNG. evm-block-builders is a full-cohort case
(Titan, BuilderNet, Quasar, Eureka, Builder+, Vanilla — none have
brand colors or logos). Fix: resolve var(...) fallbacks to the
concrete INK_SOFT / PAPER hex f…
Flotapponnier added a commit that referenced this pull request Jul 25, 2026
…ion on bench pages) (#1429)

* solana cohort: solana-official logo alias, leorpc logo + light chip, registry entries

* nft harness: keyless OpenSea mode, empty x-api-key header caused 401s (#1136)

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

* nft harness: delist rarible, free tier is 100 requests per month (#1137)

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

* robinhood chain: chains hub entry, chain-kpis, l2-block-time (HTTP poll mode), network-fees

* bridge-monitor: on-chain balance fallback + degraded flag

* bridge-monitor: self-healing metrics + slack notifiers

* bridge-monitor: auto-rebalancer, corrective transfer via cheapest bridge

* bridge-monitor: tier downgrade ladder + rebalancer wiring in pre-flight

* bridge-monitor: stuck-fund reaper, hourly stranded gauge + corrective transfer

* bridge-monitor: gas auto-top-up via LiFi same-chain swap, gated by default

* bridge-monitor: unit tests for refill route, ladder, stranded hours; fix tracker reset

* bridge-monitor: keyless dry-run support for reaper + RunDryRun

* bridge-monitor: comment style cleanup in pre-flight docs

* bridge-monitor: daily spend tracker keyed by UTC date, persisted; execution mutex primitive

DailySpentUSD only ever incremented and was zeroed by every restart, so a
crash-loop minted a fresh budget each time. The tracker resets exactly at
UTC midnight, persists (date, spent) to SPEND_STATE_PATH (default
./spend-state.json, not /tmp) and is mutex-guarded. Also declares the
package-level single-flight lock and the FAILED_TX_FEE_ESTIMATE_USD
(default 0.50) helper for gas bled by failed broadcasts.

* bridge-monitor: fix double-send on in-flight rebalance, slot-wide budget, single-flight lock

Fund-loss fixes from hostile review:
- a corrective transfer whose deposit broadcast but whose bridge status
  never resolved is now TERMINAL for the scheduler slot (funds are likely
  still in flight, a retry double-sends); only pre-broadcast failures may
  consume another attempt. executeOnBridge keeps TxHash on error paths so
  callers can tell the two apart.
- ONE corrective-transfer budget (max 2) per scheduler slot, shared across
  all downgrade-ladder rungs (was per rung: up to 6 transfers per slot).
- package-level execution mutex: scheduler slots own the wallets end to
  end, the reaper TryLocks and skips its tick instead of queueing.
- broadcasts that failed or timed out after getting a TxHash book a flat
  FAILED_TX_FEE_ESTIMATE_USD against the daily cap instead of bleeding
  unaccounted gas; all spend goes through the persisted UTC-day tracker.
- gas top-up now honors MaxDailySpendUSD before broadcasting.

* bridge-monitor: SIMULATE_BALANCES only honored in dry-run mode

The reaper (and any other consumer) used to accept simulated balances even
in production with broadcast-capable keys, so phantom numbers could
green-light real transfers. SimulateBalances now takes the execution mode
and returns nil outside dry-run; startup logs a warning when the flag is
set in a broadcast-capable mode.

* tx executor: preserve hash on ambiguous send errors, split dry-run spend state

* executor: add missing chain label, first real single-test panicked post-fill (#1141)

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

* bridge-monitor: point Mobula calls at demo-api.mobula.io (#1142)

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

* tokenized-stock-peg: bench 076, onchain tokenized equities vs Nasdaq reference (staging gated)

* spec: missing store blob for a live bench throws instead of caching the miss (bnb-rpc awaiting incident) (#1144)

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

* tsp: parse the flat spark shape (close/timestamp arrays), log empty parses

* tsp: drop invalid rank_matrix_query, 1m reference candles, fee-band + USDG methodology notes

* rwa: new category, tokenized-stock-peg moves in, 11 equity logos + registry pages

* rwa: xstocks-peg (077) + usdy-nav-basis (078), issuer label on the tsp metric family

* rwa: complete product pages, hood/qqq/coin/orca/pyth logos + dual-issuer descriptions

* registry: googl links to google.com, abc.xyz 403s aggressively

* cardano: cache koios epoch params 1h (public tier was exhausted); stellar: protocol-minimum 5 reserves

* fix(store+bridge-monitor): fail-fast on missing blob + sub-50ms histogram buckets (#1152)

* spec: missing store blob for a live bench throws instead of caching the miss (bnb-rpc awaiting incident)

* bridge-monitor: sub-50ms buckets + nanosecond precision on Observe

Histogram Buckets grew from [50, 100, 200, 500, 1000, 2000, 5000, 10000]
to [10, 25, 50, 100, 200, 500, 1000, 2000, 5000, 10000]. Near Intents
on solver-cached corridors (HyperCore in particular) returns via a
1Click coordinator fast path in <50ms, and the previous scheme lumped
every one of those observations into (0, 50] so histogram_quantile
linearly interpolated to ~25ms regardless of true value. The p50 shown
on /benchmarks/bridge-quote-latency?chain=HyperCore was therefore a
bucket floor, not a measurement. Sub-50 buckets let the real bimodality
surface.

Also swap Observe(float64(quoteLatency.Milliseconds())) for
Observe(float64(quoteLatency.Nanoseconds()) / 1e6) across all 6
per-bridge observers (across, debridge, lifi, mobula, nearintents,
relay). int64 Milliseconds() truncates every sub-1ms round-trip to 0,
same bug class as the RPC harness fix in PR #1128. Not a hot path here
(network RTT dominates), but keeps precision consistent with the new
low buckets and cheap to fix while we are already touching each file.

Bench YAML methodology + FAQ updated to reflect the new bucket set.

---------

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

* fix: 3 machine-readable surface bugs (citable unit scaling, stat filters, draft asOf) (#1154)

* fix: 3 machine-readable surface bugs surfaced by GEO audit

1. /api/citable/{date} unit scaling.
   Snapshot route emitted `value: 627` for `aggregator-head-lag` while the
   live /api/citable emitted `value: 0.63` on the same measurement. The
   live route already wraps with valueInDeclaredUnit(raw, b.unit) but the
   snapshot route was left with the raw internal-ms value. Wrap the same
   way; sub-1 s benches now render in the declared `s` unit on both routes.

2. /api/stat/{slug} silently ignored ?chain= / ?region= / ?kind= / ?venue=.
   getBenchmark accepted a filters object but the route never parsed the
   URL. A citer asking /api/stat/rpc-capabilities?chain=ethereum got the
   cross-chain aggregate. Parse the four dimension params, pass through
   to the loader; unknown values fall back to unfiltered inside the
   loader. Also widen getBenchmark options type to include kind and
   venue — the underlying loadBenchmark already supports both.

3. Draft benches leaked a fake freshness signal.
   Draft placeholder set lastRunAt to new Date for type safety
   (Benchmark.lastRunAt is non-null), and both citable routes passed it
   straight through as asOf. LLM crawlers treating asOf as ground truth
   would think a draft bench was measured every minute. Null asOf in the
   JSON when status is draft; the internal type stays intact.

* fix: propagate draft-asOf null-out to /api/stat, MCP, JSON-LD dateModified

The GEO audit review found the initial fix in commit f6f84b8 only covered
the two /api/citable JSON surfaces, but the same "draft benches spoof
freshness" class of bug lives on three more machine-readable channels
that LLM crawlers also key on:

- /api/stat/{slug} asOf field.
- MCP tool responses (list_benchmarks, get_benchmark, resource template,
  and the plain-text "Last sample" line).
- JSON-LD dateModified on the bench page, per-chain sub-page, answer
  page and alternatives page — Google, Bing, Perplexity all consume
  dateModified as a freshness ranking signal.

Centralize the guard as citableAsOf(b) in @/lib/citation, rewire every
call site. Draft benches now uniformly omit or null the freshness
timestamp; live benches unchanged. On the visible /answers page,
the "Data as of..." line and its <time dateTime> anchor also hide for
drafts rather than displaying the wall-clock placeholder.

Follow-up to PR #1154 (commit f6f84b8) covering the surfaces the review
agent flagged as still spoofing.

---------

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

* feat: /api/compare/{a}/{b} + openapi.json coverage (Wave 1 P0/P1) (#1156)

* feat: /api/compare/{a}/{b} + openapi.json coverage

Two GEO surfaces flagged by the audit as high-value gaps.

## /api/compare/{a}/{b}

Head-to-head comparison as a single citable payload. LLMs asked "Mobula
vs Codex head lag" would otherwise need two /api/stat calls plus their
own delta reasoning; this endpoint returns one flat structure with per
benchmark p50 for each side, the winner on the metric's
higher-is-better convention, and the absolute delta.

The intersection logic mirrors src/lib/related-providers.ts and the
HTML /compare/{slug} page: shared benchmark = both providers appear in
it. Intentionally returns flat leader-p50 only (no chain/region
breakdowns, no cache-heavy fan out) so the agent tool call stays fast.

## openapi.json coverage

Added /api/citable/{date} (was missing despite being a real API
endpoint) and /api/compare/{a}/{b} to the OpenAPI 3.1 spec. Also
documented the dimension query params (chain/region/kind/venue) on
/api/stat/{slug} that shipped in the previous PR but were undocumented.
Added Compare schema; extended Stat schema with `filters` field.

/llms.txt and /llms-full.txt stay outside the OpenAPI surface — they
are text files not RPC endpoints, and OpenAPI consumers do not handle
plain-text specs well anyway.

## citableAsOf helper widened

Type signature relaxed from full Benchmark to
`Pick<Benchmark, "status" | "lastRunAt">` so the slim
ProviderAppearance.benchmark used by the new compare route can pass
through without a downcast. No behavior change on existing callers.

## Notes on items NOT shipped in this batch

- Cache-Control on /llms.txt / /api/citable / /api/llm-context: the
  audit report showed `cache-control: public` and inferred no CDN
  caching, but the source already sets
  `public, s-maxage=300, stale-while-revalidate=900`. Vercel strips the
  `s-maxage` directive from the client-visible header while honoring it
  at the edge; `x-vercel-cache: HIT` with `age: 256` confirms the CDN
  cache is active. Not a real bug.
- .bib / .ris file MIME endpoints: string versions already ship inside
  the /api/stat JSON via cite.bibtex / cite.apa. Marginal upside for
  Zotero-style one-click imports; downgraded to P2.
- /api/stat/{slug}/sparkline.svg: sparkline data already ships as a
  float array in /api/stat and the OG image at /api/og/{slug} covers
  the "embed a chart in an answer" case. P2.
- JSON-LD `Dataset.variableMeasured.value`: current schema emits bare
  strings; upgrading to PropertyValue objects would duplicate the
  measured value already carried by the StatisticalReport node's
  Observation.measuredValue. P2.

* openapi: add totalWins to Compare schema

---------

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

* feat: Wave 1 P2 batch — cite MIME, sparkline SVG, Dataset PropertyValue (#1158)

* feat: Wave 1 P2 batch — .bib/.ris/apa MIME, sparkline SVG, Dataset PropertyValue

Three GEO surface additions the audit downgraded to P2. Each one is
duplicative with an existing channel (cite.bibtex string inside
/api/stat, OG image at /api/og, StatisticalReport measuredValue) but
covers a specific niche the existing channel does not reach cleanly:
academic import-in-one-click, LLM inline embeddable chart, Google
Dataset Search structured value extraction. Ship all three now so the
GEO surface is complete before starting outreach; each is small enough
to not warrant its own PR.

## /api/cite/{slug}/{format}

Per-bench citation with the correct MIME type. Four formats:
  - bib -> application/x-bibtex (Zotero, LaTeX)
  - ris -> application/x-research-info-systems (EndNote, Mendeley)
  - apa -> text/plain (ready-to-paste APA string)
  - txt -> text/plain (ready-to-paste plain attribution)

Content is the same as the cite.* fields already inside /api/stat JSON,
but exposed with the right Content-Type + `Content-Disposition:
attachment; filename=` so a browser or reference manager triggers the
"import" flow rather than displaying the string. Added RIS to CiteBundle
in src/lib/citation.ts (TY GEN neutral fallback, AU / TI / PY / UR / Y2
/ ER fields, CRLF line endings per RFC convention).

## /api/sparkline/{slug}

Standalone SVG sparkline of the current leader's 24 h series. Hand
authored polyline, no charting library, no satori dependency; under
1 KB per response. Query params: `w` (40..1200, default 240), `h`
(20..400, default 60), `theme=light|dark`. Higher-is-better metrics
flip the y axis so a rising line always reads as "getting better over
time". Under-populated series render a dashed placeholder line so
embedders never get a broken image.

Cases this reaches that /api/og and the raw JSON sparkline array do
not: img/svg inline embeds in Perplexity Pages, ChatGPT answers, dev.to
posts, Notion pages. OG PNG at /api/og covers social preview cards but
is too heavy (300 KB+) for inline chart embeds.

## Dataset.variableMeasured -> PropertyValue

Bare-string array in Dataset JSON-LD upgraded to include structured
PropertyValue objects when the current leader has real numeric
aggregates. Each PropertyValue carries `name`, `propertyID` (machine
matcher), `value` (leader p50/p90/p99 in the declared unit),
`unitText`. sample_size stays a bare string — it labels an axis, not a
measurement. Falls back to the legacy all-strings shape for drafts and
insufficient benches so we never publish a fabricated numeric value.

New helper: buildBenchVariableMeasured() in src/lib/dataset-jsonld.ts.
BenchDatasetInput.variableMeasured now accepts
Array<string | VariableMeasuredValue>. `keywords` extraction pulls the
`.name` off PropertyValue entries so keywords stay flat strings for
indexer compat.

## openapi.json

Both new endpoints registered under paths, so agent frameworks
discovering the API through OpenAPI can call them.

* sparkline: XML-escape bench title + provider name in SVG

---------

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

* bridge-monitor: Mobula EIP-712 signature-confirm flow (fixes stuck deposits) (#1160)

* bridge-monitor: implement Mobula EIP-712 signature-confirm flow (fix stuck deposits)

* mobula sig: inject EIP712Domain type definition, API omits it

---------

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

* robinhood chain vague 1: weekend drift (079) + arb latency (080) + subsidy tracker (081)

* subsidy is a chain KPI, not a bench: kill standalone spec, add 3 cards to /chains/robinhood strip

* pm-api-latency: add Predexon (bench 6th provider), fresh key deployed on the 3 Railway probe regions

* usdy: 3-retry on Orca RPC to fix 40% success rate (#1168)

publicnode.com burst-limits the VPS on getAccountInfo, dropping
~60% of samples. Two retries with 500ms backoff bring hit rate to
>95% without switching endpoints. Also add commitment=confirmed
to reduce slot-miss null returns.

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

* bench vague 2: ship 081-085 (ws latency, oracle freshness, rpc reliability, indexer latency, block builders) (#1170)

* add bench 081 ws-head-latency: WS block-push lag harness + spec

* add bench 084 indexer-latency: HyperSync vs The Graph vs Alchemy time-to-queryable

* idx-latency: only classify error when every poll errored, else timeout

* bump bench-set cache keys for 084 (lockstep rule)

* add bench 082 oracle-freshness: staleness metrics in oracle-deviation harness (multi-chain chainlink, pyth publish_time fix, redstone), bump bench-set cache keys

* oracle-freshness: count unsupported mainnet feeds in freshness error counter

* add bench 085 evm-block-builders: ETH builder share via extraData + relay bidtraces + Arb/Base soft-conf lag, bump bench-set cache keys

* ebb: don't count historical relay payloads on first poll, retry arb calibration on zero support, reset WS backoff after stable session

* add bench 083 rpc-reliability: consensus lag, hash quorum, fixed-vector integrity in rpc-capabilities harness, bump bench-set cache keys

* bench 083: clamp_min rank_matrix_query so zero-incident providers keep cell ranks

* gate benches 081-085 behind staging until 48h clean data

* drop shipped RWA slugs from prod gate (live on main since #1162/#1167)

---------

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

* hyperliquid frontend page: add ProviderLogo in the hero next to the name

* hl frontends: add slash, topdog, markets-mobile logos + registry entries; dark-mode chip for slash + markets-mobile

* idx-latency: swap Alchemy for Mobula in the cohort (paid-plan key, bench races $0 pipelines only) (#1176)

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

* fix(bench): sample-size PromQL + leader reliability floor (#1175)

* fix(bench): sample-size PromQL + leader reliability floor

Two bench-correctness fixes surfaced by an audit of the oracle and
gas surfaces we plan to cite in outreach.

Bench 025 (oracle-deviation):
- sample_size was 'count(count by (source) (ocb_oracle_price))'
  which returns the count of distinct sources (3 or 4), not the
  count of samples over the window. The citable API therefore
  reported sampleSize=3-4 on every pair, at odds with the spec's
  'polled every 30s' claim. Rewrites the query to
  'sum(count_over_time(ocb_oracle_price[24h]))' across all 10
  pair entries so the number reflects actual poll volume.

Leader ranking:
- citationCandidates() now excludes providers with a measured
  success rate below 50 percent from the leader pool. Before this
  fix, gas-estimation showed Owlracle as #1 with successRate=6.48
  and a p50 gap of 0.001 gwei: technically accurate on the rare
  successful poll but useless as a leader claim. Falls back to the
  full live pool when every provider is under the floor so a
  totally degraded bench still surfaces a best-of-bad-options
  leader rather than vanishing. Freshness benches without a
  success query default to 100 percent in the loader so the guard
  is inert there.

Adds src/lib/citation.test.ts with 5 tests covering the Owlracle
scenario, higher-is-better ranking, and the degraded-pool
fallback.

* fix(bench): propagate reliability floor to rankings + template + hub

Follow-up on the previous commit. Review flagged that filtering the
leader without filtering the ranking arrays leaves self-contradictory
JSON: /api/stat can report leader=Etherscan while rankings[0]=Owlracle
on the same document.

Exports citationCandidates and adds rankedCandidates helper that
returns the same eligible pool sorted by the bench's higher_is_better
direction. leader() and fieldValue() now share it.

Wires the shared helper into every surface that emits a rankings-like
list or a best-of claim:

- src/app/api/stat/[slug]: rankings array now sorted from
  rankedCandidates so rankings[0].slug always equals leader.slug on
  the same JSON.
- src/app/api/llm-context: the Rankings numbered list under each
  bench matches the Headline sentence above it.
- src/app/api/mcp/[transport]: get_benchmark tool response and the
  openchainbench://benchmark/{slug} resource both share the pool.
- src/lib/bench-template: {{best_name}} / {{best_p50}} / {{worst_name}}
  tokens in bench copy resolve against the same pool so a bench body
  never crowns a provider its own headline excludes. Per-slug tokens
  {{p50:some-slug}} still resolve against the raw live pool so unknown
  tokens still fall through.
- src/lib/providers: rankProviders (drives /products hub wins count
  and per-chain leadership chips) now runs on the reliable pool.

Adds a consistency test locking that rankedCandidates[0].slug always
matches leader().slug on the same bench. Total: 6 tests in
citation.test.ts, 95/95 pass across src/.

Uses (r.successRate ?? 100) for the numeric guard to future-proof
against ProviderResult shapes that skip the field. Load path still
defaults to 100 (materialize/load.ts:748) so this is inert today, but
the extra ?? removes the fragility.

/api/compare/[a]/[b] is intentionally left unfiltered: the user
picks the two providers explicitly, so the head-to-head is a
requested comparison rather than a leader claim. Applying the floor
there would drop rows the user explicitly asked to see.

* fix(hub-card): honor citation leader in the hub grid headline

Follow-up review flagged that benchmark-card.tsx re-sorted b.results
locally and picked results[0], so a bench where citation demotes the
raw-p50 winner (e.g. Owlracle on gas-estimation) would show the
demoted provider on the hub grid while the bench page names the
correct leader. Two documents contradicting each other in one click.

Adds leaderSlug: string | null to BenchmarkCardData and populates it
at the projection boundary via leader(b), which already applies the
reliability + insufficient-sample filter. The card picks the leader
by matching that slug in the projected results, and falls back to the
raw best when the projection returned no leader (draft, insufficient,
or all providers filtered out) so a card still renders a headline
value in every state.

Kept the local sort so the fallback path and any downstream chip
using sorted[0].name keeps working.

Test suite unchanged: 95/95 pass across src/. No test needed since
this is a projection-boundary wire-up.

---------

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

* fix(registry): remove duplicate topdog entry (#1177)

Commit 349b786 (hl frontends: add slash, topdog, markets-mobile logos +
registry entries) added a 'topdog' entry at line 313 without noticing a
pre-existing 'topdog' entry at line 1061, breaking staging build with a
duplicate object literal type error.

Keeps the newer entry (Telegram bot at t.me/topdog_trade_bot, HL
frontend context) since it was the one intentionally added for the
Hyperliquid frontends leaderboard chip work. Drops the older Discord
signal bot entry (topdog.gg): the bench spec at
benchmarks/hyperliquid-frontends.yml has slug: topdog with name TopDog
and matches the newer, HL-native product.

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

* vague2 QA: unit sec for true-seconds benches (fixes 0.0s heros), soften 080 findings, drop dead 085 builder rows, 083 expected_n 40 (#1178)

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

* add bench 086 polkadot-rpc: first Substrate chain in the RPC cluster (#1179)

Extends rpc-capabilities to Polkadot, the first non-EVM, non-Solana
chain in the cluster. Substrate JSON-RPC via chain_getHeader (returns
hex-encoded block number matching EVM's eth_getBlockByNumber shape),
staleness by relay-block gap (40 blocks, ~4 min at Polkadot's ~6 s
cadence).

Harness changes:
- Chain.Kind gains "polkadot" alongside "solana" and default (EVM).
- New callSubstrateHeader: chain_getHeader POST, parses hex block
  number, returns parentHash as the observed hash. Same latency
  measurement + result classification as the EVM path.
- probeOne switches on Kind (was if solana / else). polkadot uses
  polkadotStaleBlockGap = 40. Consensus observation is skipped
  because chain_getHeader returns parentHash (N-1), not the current
  block hash (deriving current hash needs Blake2 over the SCALE-
  encoded header, out of scope).
- Archive loop is skipped for polkadot the same way as solana:
  Polkadot state accesses via state_getStorage keyed by Blake2 storage
  keys, no chain-agnostic depth analog for eth_getBalance-by-depth.

Providers at launch (3, live-verified on 2026-07-15 with four
consecutive chain_getHeader probes each):
- polkadot-official (Parity, rpc.polkadot.io)
- onfinality (public gateway, Web3 Foundation grantee)
- publicnode (Allnodes, extends its multi-chain footprint to
  Substrate keyless)

Audited and excluded from the launch cohort:
- 1RPC: filters chain_getHeader as "Not Allowed" on the privacy
  relay. Revisit if they expose a Substrate-friendly method set.
- Dwellir: 503 Service Unavailable during the audit sweep, revisit
  once the gateway stabilises.
- Ankr: paid Polkadot tier only.
- Chainstack: key-gated on Substrate.
- RadiumBlock: rejects without referral header.
- Grove / Thirdweb: invalid-chain response.

Spec (benchmarks/polkadot-rpc.yml, bench № 086):
- Same schema as moonbeam-rpc / avalanche-rpc etc. so the parent
  rpc-capabilities benchmark, chain cluster page and hub card
  render it identically.
- Methodology documents the Substrate specifics: probe method
  choice, staleness scaling, why no archive-depth or hash-quorum
  columns.
- SEO/finding tokens use the standard {{best_name}} / {{best_p50}}
  / {{p50:slug}} template surface, consistent with other -rpc
  benches.
- Provider tag copy explains OnFinality and Dwellir's Web3
  Foundation grantee status so the leaderboard reads correctly
  for someone who knows the Polkadot infra landscape.

Local checks:
- go build ./... clean.
- go test ./... clean.
- bun test src/ - 95/95 pass across 10 files, including the
  citation reliability floor and consistency tests added in #1175.

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

* product pages: 9 block-builder registry entries + verified logos, oracle logos (pyth/redstone) (#1181)

* products: registry entries + logos for bench 085 block builders

* oracle logos: redstone official logo + pyth/redstone manifest + registry entries

---------

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

* 085: add bobthebuilder provider row (real share, was metrics-only) (#1182)

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

* polkadot-rpc bench 086 + /chains/polkadot + Parity product (#1184)

* add bench 086 polkadot-rpc: first Substrate chain in the RPC cluster

Extends rpc-capabilities to Polkadot, the first non-EVM, non-Solana
chain in the cluster. Substrate JSON-RPC via chain_getHeader (returns
hex-encoded block number matching EVM's eth_getBlockByNumber shape),
staleness by relay-block gap (40 blocks, ~4 min at Polkadot's ~6 s
cadence).

Harness changes:
- Chain.Kind gains "polkadot" alongside "solana" and default (EVM).
- New callSubstrateHeader: chain_getHeader POST, parses hex block
  number, returns parentHash as the observed hash. Same latency
  measurement + result classification as the EVM path.
- probeOne switches on Kind (was if solana / else). polkadot uses
  polkadotStaleBlockGap = 40. Consensus observation is skipped
  because chain_getHeader returns parentHash (N-1), not the current
  block hash (deriving current hash needs Blake2 over the SCALE-
  encoded header, out of scope).
- Archive loop is skipped for polkadot the same way as solana:
  Polkadot state accesses via state_getStorage keyed by Blake2 storage
  keys, no chain-agnostic depth analog for eth_getBalance-by-depth.

Providers at launch (3, live-verified on 2026-07-15 with four
consecutive chain_getHeader probes each):
- polkadot-official (Parity, rpc.polkadot.io)
- onfinality (public gateway, Web3 Foundation grantee)
- publicnode (Allnodes, extends its multi-chain footprint to
  Substrate keyless)

Audited and excluded from the launch cohort:
- 1RPC: filters chain_getHeader as "Not Allowed" on the privacy
  relay. Revisit if they expose a Substrate-friendly method set.
- Dwellir: 503 Service Unavailable during the audit sweep, revisit
  once the gateway stabilises.
- Ankr: paid Polkadot tier only.
- Chainstack: key-gated on Substrate.
- RadiumBlock: rejects without referral header.
- Grove / Thirdweb: invalid-chain response.

Spec (benchmarks/polkadot-rpc.yml, bench № 086):
- Same schema as moonbeam-rpc / avalanche-rpc etc. so the parent
  rpc-capabilities benchmark, chain cluster page and hub card
  render it identically.
- Methodology documents the Substrate specifics: probe method
  choice, staleness scaling, why no archive-depth or hash-quorum
  columns.
- SEO/finding tokens use the standard {{best_name}} / {{best_p50}}
  / {{p50:slug}} template surface, consistent with other -rpc
  benches.
- Provider tag copy explains OnFinality and Dwellir's Web3
  Foundation grantee status so the leaderboard reads correctly
  for someone who knows the Polkadot infra landscape.

Local checks:
- go build ./... clean.
- go test ./... clean.
- bun test src/ - 95/95 pass across 10 files, including the
  citation reliability floor and consistency tests added in #1175.

* polkadot page + parity/onfinality/dwellir products

Wires the /chains/polkadot hub page, provider profiles, and logo
assets that surface once bench 086 polkadot-rpc starts feeding Prom.

- chains.ts: add Polkadot to CHAINS registry (L1, nativeSymbol DOT).
  Auto-populates /chains/polkadot once benches referencing chain="polkadot"
  land. Adaptive KPI strip fills in DefiLlama TVL, DEX volume and stables
  when the chain-kpis harness starts sourcing Polkadot alongside the
  other L1s.
- provider-registry.ts: three new entries for the Polkadot cohort.
    * parity: brand entry for Parity Technologies (Polkadot / Substrate
      implementation shop, operator of rpc.polkadot.io).
    * polkadot-official: endpoint-level entry describing the reference
      rpc.polkadot.io keyless endpoint; providers.ts aliases the slug
      to parity so the /products page collapses under the operator's
      brand.
    * dwellir: brand entry for Dwellir (Web3 Foundation grantee, keyless
      Substrate infra) — excluded from the 086 launch cohort due to a
      503 during the audit sweep but kept in the registry for the
      revisit and for other places their brand might appear.
- providers.ts: add polkadot-official → parity alias and canonical
  brand casings for polkadot / parity / onfinality / dwellir so the
  /products hub, badges, and OG images render the correct name
  without the title-case fallback butchering them.
- logo-manifest.ts: polkadot.png (chain hero), parity.png (product page
  logo), plus polkadot-official → parity slug alias so the endpoint
  chip on the bench page picks up Parity's mark automatically.

95/95 pass on bun test src/. Local build clean.

---------

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

* fix: dedup onfinality key in logo-manifest (already declared line 49) (#1186)

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

* store: last-known-good snapshot fallback (no more awaiting after first publish) (#1188)

publishSnapshot writes an lkg copy without TTL on every success;
readMaterialized falls back to it when the pointer/blob pair misses
(worker outage past blob TTL, DEL race, transient). dataAsOf keeps the
freshness badge honest about age. LKG write is best-effort and never
fails the publish.

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

* chain-kpis: add polkadot to registry (DOT native, stables via Asset Hub) (#1191)

Ships live KPIs on /chains/polkadot. DOT price and mcap flow through
Mobula. Stables card renders via DefiLlama Asset Hub aggregate.
TVL and DEX gauges stay unpublished (relay chain has zero DeFi:
parachain TVL lives under Acala, Moonbeam, Hydration).

Also guards defillama TVL publish to skip zero values so chains that
DefiLlama tracks by name but with an all zero series (Polkadot today)
hide the TVL card instead of rendering $0.

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

* live-feed status: Feed down pill + banner on aggregator-head-lag (#1193)

Short-window changes() probe per provider surfaces silent live feeds
that the 24h quantile aggregate hides (Codex WS blackout 08:23-09:04
UTC today moved the p50 by nothing but the whole feed was mute for
40min). Bench-level probe_ok gate suppresses badges when our harness
side is broken so a Railway crash cannot fake-flag every provider.

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

* series gaps: null the chart line when a feed goes silent (unless changes==0) (#1195)

Companion to feed-down banner: makes the 40min Codex blackout from
this morning (08:23-09:04 UTC) visible on the 24h chart instead of a
flat line covering the outage. Per-series match preserves the
aggregate line as long as one chain still receives events.

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

* add bench 087 ws-head-latency-base + 088 ws-head-latency-solana (#1196)

Per-chain siblings of bench 081 for the WebSocket head-lag cluster.
Same harness (harnesses/ws-head-latency), same race scoring, one
spec per chain to avoid mixing block cadences (Ethereum 12s vs Base
2s vs Solana 400ms) in a single leaderboard.

Base cohort: PublicNode + dRPC keyless endpoints. Tenderly does
not expose a keyless Base WebSocket path.

Solana cohort: PublicNode + Solana Foundation. slotSubscribe
replaces newHeads on the harness side; metric names are shared so
the queries are structurally identical to the EVM specs.

Data already flows via the Paris VPS deployment (chain label carries
base and solana). No harness change needed for this ship. Multi-region
is a follow-up on the parent bench.

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

* ws-head-latency: multi-region (harness region label + spec region tabs) (#1198)

* ws-head-latency: region label via ConstLabels + Railway config

Adds a static region ConstLabel to every ws-head-latency metric,
bound at process startup from OCB_REGION (preferred) or
RAILWAY_REPLICA_REGION (Railway auto-set), with eu-west fallback
matching the historical single-vantage Paris VPS deployment.

No observation site needs to change: the label is baked into every
counter, gauge and histogram at registration, so all existing
WithLabelValues(provider, chain) calls stay identical.

Adds railway.toml so Railway can build the harness from Dockerfile
with a /health check, matching the rpc-capabilities service layout.

Follow-up: create the Railway ws-head-latency service with 3
replicas (us-east, eu-west, sgp) and OCB_REGION set on each, then
decommission or leave the Paris VPS container as a 4th vantage.

* ws-head-latency specs: add region dimension tabs (081 + 087 + 088)

Adds `dimensions.region` block + `rank_matrix_query` on provider,
region + per-provider `regions:` sub-arrays with region-scoped
queries on the 3 ws-head-latency specs, following the polkadot-rpc
pattern.

Depends on the harness region label change (previous commit): every
metric now carries a region ConstLabel so the region-scoped
histogram queries return values as soon as multi-region replicas
publish. Until Railway 3-replica deploy lands, only region=eu-west
has data and the other tabs render empty; the All regions tab keeps
working because the aggregate query is unscoped by region.

---------

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

* ws-head-latency: align region env var with rpc-capabilities (REGION not OCB_REGION) (#1200) (#1201)

Every other multi-region harness on Railway (rpc-capabilities, aggregator-head-lag, evm-swap-quoting, pm-rate-limits, etc.) reads REGION from env. Making ws-head-latency read REGION too keeps the Railway service config uniform across the whole harness fleet.

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

* chart: red band overlay for null-bucket downtime windows (#1203)

Companion to #1195 (series null gaps). The gaps themselves render as
line breaks but a break can read as 'the chart missed a sample'. A
soft red column behind the break spans the exact outage window and
makes the reader understand it was a real feed-side silence. Per
line + per contiguous gap range, extended by one step so 1-bucket
outages stay visible instead of collapsing to a zero-width sliver.
Hidden for excluded (legend-toggled) providers.

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

* rename ws-head-latency -> ws-head-latency-ethereum for sibling slug parity (#1204)

081 becomes ws-head-latency-ethereum so the 3 per-chain benches in the head-lag cluster share the same shape: ws-head-latency-ethereum / -base / -solana. The old ws-head-latency slug is dropped (was staging-gated via REMOVED_BENCH_SLUGS, never live on prod, no external SEO to preserve).

Ungated on prod at the same time so the new slug ships alongside the base + solana siblings that are already live. Sibling text references updated to the new URL. Cache keys bench-unfiltered and all-benchmarks bumped so cached v29/v32 entries do not keep the old slug alive.

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

* network-coverage: drop coinstats (free tier credits exhausted) (#1206)

CoinStats /wallet/blockchains returns HTTP 406 "Credits limit reached" on every fetch: the free tier quota is spent below our 6h cadence and the provider never publishes a network count. Dropping until a paid key is provisioned.

Removes the coinstats.go adapter, the CoinStatsAPIKey config field, the coinstats entry in main.go, and the coinstats provider row + prose mentions in the spec. Cohort drops 6 -> 5 providers (GeckoTerminal, Codex, Mobula, CoinPaprika, Dune via Sim API).

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

* Revert "network-coverage: drop coinstats (free tier credits exhausted) (#1206)" (#1208)

This reverts commit d295f7168da981078164119e508ca9546ed69fa2.

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

* downtime bands: provider-colored tint + name/silent label + edge markers (#1210)

Feedback on #1203: the neutral red band read as 'something happened
here' without telling the reader WHO. Tint each band in the
provider's own color, add dashed edges at the outage boundaries, and
label 'PROVIDER · SILENT' above the band when it fits (with a caret
that survives on narrow single-bucket bands). Works uniformly for
Mobula/Codex/Gecko or any future provider with a live_activity
query.

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

* downtime bands: pill labels + vertical stacking on overlap (#1211)

Screenshot review: the small text-only label above the band was still
too subtle to read as 'this provider is down', and 3 simultaneous
provider outages produced overlapping unreadable labels stacked on
the same y. Replace with high-contrast rounded pills (white text on
provider color) that stack vertically when their X-ranges overlap.
Renders as '● NAME DOWN' in bold small caps so a reader gets the
event and the provider identity at a glance.

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

* tsp-arb-latency: lower trigger 50 -> 25 bps + credit sub-poll closures (#1212)

Bench 080 published only 7 samples across 3 of 11 assets in 24h at
50 bps trigger. Two contributing causes fixed together:

1) Trigger from 50 -> 25 bps. 50 bps in 90s is a headline move on
   Nasdaq (earnings, macro prints), not a steady-state event. 25 bps
   is above normal intra-minute noise on liquid stocks and captures
   real arb-worthy moves without collapsing the semantic.

2) Sub-poll convergence credited. When the reference moves past the
   trigger AND the pool is already within band on the same tick, the
   arb closed inside one poll interval. Previously dropped silently;
   now observed at pollInterval (60s) so the fast-arb tail on liquid
   pools stops disappearing from the histogram.

Convergence threshold stays at 20 bps: the "how close is the pool to
fair" question is unchanged, only trigger sensitivity moves. Spec
text (subtitle, methodology, FAQ) updated to 25 bps throughout.

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

* downtime pill: drop the leading dot indicator, center the label (#1213)

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

* downtime pill: 'DATA MISSING' wording + skip leading gaps (#1215)

Two fixes based on 30D view review:

- Leading gaps (Prom retention shorter than the visible window,
  harness started mid-range, provider added recently) rendered as
  a chart-wide band claiming every provider was down for the
  missing period. Skip runs that started before the first observed
  sample.

- Rename 'DOWN' to 'DATA MISSING'. A silent series can be the
  provider going down, our harness losing its WebSocket, or a Prom
  scrape failure — the pill stays neutral instead of blaming the
  provider.

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

* drop bench 084 indexer-latency (harness pruned, spec deleted) (#1218)

HyperSync and The Graph need paid keys for the sustained cadence the
bench expects; Mobula alone left the leaderboard single-provider and
the spec never made it out of REMOVED_BENCH_SLUGS gating. Simpler to
drop the whole cluster than to buy 2 SaaS subscriptions to prop up
a bench nobody has cited yet.

Removed:
  * benchmarks/indexer-latency.yml
  * harnesses/indexer-latency/ (whole directory)

Kept in REMOVED_BENCH_SLUGS as 410-gone so any indexed URL returns
Gone rather than 404. Cache keys bumped so cached v30/v33 entries
that still enumerate the slug age out on next revalidate.

VPS follow-up (separate ops): stop ocb-indexer-latency container,
remove from docker-compose.yml + prometheus.yml scrape config.

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

* video/render: strict Zod body schema before upstream forward (#1216)

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

* mcp: allowlist perp_funding_*, perp_venue_*, perp_execution_* prefixes (#1220)

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

* aggregator-head-lag: drop 'unless changes==0' from series queries (#1221)

Bug: the guard was designed for modern 15s-cadence data, where
changes([15m]) works cleanly. On backfilled historical data compacted
to ~1 sample/hour, changes([15m]) returns 0 even though the gauge is
healthy — the 30D view lost the first 8 days of data to false-drops
(all three providers marked DATA MISSING for a whole week).

Real-time silence is still surfaced by the live_activity scalar
(pill + banner + downtime band), which runs against fresh scrape
cadence and stays accurate. The chart series now shows every point
Prom has, flat during old outages instead of gapped.

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

* seo: 200 placeholders + sitemap threshold buffer (#1222)

- badge/[slug]/[provider]: serve pending SVG (200) instead of 404 when a provider is missing ranking data
- hyperliquid/[slug]: render lightweight placeholder page (200) instead of 307 to /hyperliquid when history blob is missing the builder
- compare adHocPairs: bump inclusion threshold to 3 (brand) / 4 (other) so a single bench flap can't cross both the sitemap gate and the page noindex gate

Fixes 3 recurring Ahrefs issues (broken badge images, noindex-in-sitemap on compare, 3xx-in-sitemap on hyperliquid) all rooted in the sitemap/page ISR race.

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

* aggregator-head-lag: restore outage detection with backfill-safe guard (#1224)

Follow-up to #1221. Dropping the guard entirely restored the 30D
range but silently hid today's Codex morning blackout on the 24H
view (frozen gauge value = flat line, indistinguishable from a
healthy quiet period).

Bring the guard back, gated on count_over_time > 5 samples in the
window. Modern data at 15s cadence carries ~60 samples per 15 min
so the gate fires the moment a WS goes silent. Backfilled data
compacted to ~1 sample/hour has count <= 2 in a 15m window so the
gate stays open and every point Prom kept is served — no more fake
wall-to-wall gap at the start of the 30D range.

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

* ws-head-latency: sub-ms histogram floor + keyed slots for base/solana (#1225)

Fix the leader-collapses-to-2.5ms artifact: histogram_quantile on a
[0,5ms] first bucket interpolates every winner's p50 to ~2.5ms
(chain-independent). Replace with a 0.5ms floor climbing to 10s so
sub-ms wins actually resolve on Solana (~400ms slots) and Base (2s).

Add keyed provider slots for Base (Alchemy/Infura/Chainstack/QuickNode)
and Solana (Alchemy/Chainstack/Helius/QuickNode) so the cohort can
extend beyond keyless once contributor keys are wired. Keyless tier
is saturated on all three chains (audit failed: Ankr, Blast, LlamaRPC,
BlockPI, 1RPC, NodeReal, Omniatech, base.org, extrnode, chainstack-
public — none accepted a keyless WSS handshake as of 2026-07).

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

* ship: ungate bench 085 evm-block-builders on prod (#1227)

Titan leads with sample=6973 in 24h, data healthy since the harness
ship. Removing from REMOVED_BENCH_SLUGS so the slug renders on prod
next revalidate. Cache keys bumped to bench-unfiltered-v32 +
all-benchmarks-v35 so cached entries do not keep dropping the slug
via the old prod gate.

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

* loader: propagate 24h series null-runs to 7d/30d (#1229)

Fine-grained outages (24h step = 20 min buckets) rarely align with
coarser evaluation timestamps (7d step = 2h, 30d step = 12h) so the
Prom query returns a healthy average and the reader sees the pill on
24H but nothing on 7D/30D. Post-process: for every contiguous null
run detected on s24, translate the time-ago range to bucket indices
on s7 and s30 and null every coarse bucket the run straddles.

Contiguity preserved so a single short outage doesn't split into two
adjacent pills after propagation. Same treatment applied to per-region
series.

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

* propagateNullsToCoarser: use absolute time-ago instead of window fraction (#1230)

Bug: the previous math scaled by the fine window fraction, which
projected a 7h-ago outage down to 40h-ago on the 7d grid (the pill
landed at -1.7d instead of -6h). Both grids share 'now' as their
right anchor and only the step sizes differ — take the fine idx's
absolute time-ago and divide by the coarse step to find the
covering bucket.

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

* tsp-arb-latency: drop 5 illiquid tickers (TSLA GOOGL AMZN MSFT SPY) (#1231)

Their Uniswap v4 pools on Robinhood Chain have no arb activity: pool spot stays frozen at the last swap value tick after tick while the Yahoo reference moves normally. Events open on ref triggers but roll over as still_open with zero latency samples. Cohort restricted to the 6 tickers with observable arb activity: NVDA AAPL PLTR META AMD MU.

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

* perp-fees harness: fix Paradex depth-skip, gains SOL slot, dYdX walk order, GMX Subsquid error surfacing (#1233)

- Paradex: cap fill ratio at 90% of visible book (depth=100). Tiers that eat through the tail now return book_too_thin and skip publishing instead of inflated 150-500 bps ghost values.
- gains: only cache USD-quoted pairs during pair discovery, widen scan 60->200. Root cause was a SOL/BTC pair overwriting the SOL/USD cache entry, publishing the wrong feeIndex and uniform 4.333 bps across ETH/BTC/SOL.
- dydx: sort v4-indexer level responses (asks ascending, bids descending) before walking the book. Insertion-order walk produced 1.7-2x inflated headline at 100k/1M tiers.
- gmx: prefix Subsquid errors, surface GraphQL errors[], fall back to unsuffixed positionFeeFactor if the impact-branch field is absent, and error out on zero factor. Prevents the silent-6bps-constant fallback path.

Verified: go build + go vet clean.

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

* downtime bands: opt-in via live_activity, skip on other benches (#1235)

Screenshot review: rpc-reliability rendered a 'LAVANODIES DATA MISSING'
pill even though its spec never declared live_activity — natural
nulls in its series24h (sparse scrapes, backfill edges) triggered
the DowntimeBands render. Gate the render on
benchmark.results.some(r => r.liveStatus != null), which is only set
when the spec declared live_activity. Same gate on
propagateNullsToCoarser so other benches' 7d/30d series stay
untouched.

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

* batch 2: MEV chips + rpc copy + Aster/edgeX + Cardano/Sui/Cosmos/Avax (#1236)

* site: provider_notes chip on ranking rows + rpc-capabilities copy fixes

- validator-yield now surfaces a per-provider chip (MEV inc./MEV excl./no MEV) beside each row so scan-readers can't accidentally compare Solana MEV-inclusive APR against Ethereum consensus-only APR without seeing the caveat
- New optional spec field provider_notes: {slug: string} plumbed end-to-end (Zod schema, TS type, materialize loader, spec overlay, ledger-table render)
- Chip renders only when the note is set, no visual change to any other bench
- rpc-capabilities: '10 EVM chains' copy corrected to 26 chains (24 EVM + Solana + Polkadot) across title/subtitle/seo_intro/abstract/methodology + FAQ
- rpc-capabilities disclaimer expanded to call out that the 'All chains' aggregate rewards single-chain foundation endpoints and to document Cloudflare-eth exclusion (returns -32603 on POST)

* perp-fees harness: add Aster + edgeX venues

- Aster (BSC): Binance-style depth walk against fapi.asterdex.com, hardcoded 5 bps taker (no public fee schedule API)
- edgeX (offchain CLOB): getDepth REST + hardcoded 3.8 bps taker per gitbook docs. Asset→contractId map declared inline (verify on first prod run; wrong ID surfaces as an orderbook fetch error, not a silent 0)
- Both venues probed for ETH/BTC/SOL at all 3 notional tiers ($1k/$100k/$1M) via applyBookTiers
- Closes the biggest coverage gap flagged by the perp-fees GEO audit (Aster is #2 perp DEX by 30d volume)

* validator-yield harness: add Cardano, Sui, Cosmos Hub, Avalanche

Closes the 'Highest staking yield' coverage gap: the bench previously ranked only Solana, Ethereum and Hyperliquid but AI answers to that question almost always include Cardano ($14B staked) and other top chains.

- Cardano (Koios API): top-50 pools by stake, net = ρ×(1-τ)×(1-margin). Simplified vs full a0/k/saturation formula but lands within ~50-100 bps of pooltool.io.
- Sui (public RPC suix_getValidatorsApy + suix_getLatestSuiSystemState): all active validators, apy is already net of commission per Sui docs.
- Cosmos Hub (LCD REST): top-200 bonded validators, net = inflation×(1-communityTax)/bondedRatio×(1-commission).
- Avalanche (P-Chain platform.getCurrentValidators): top-100 by stake, base 8.5% APR × uptime × (1 - delegationFee/100).

Reuses existing ocb_validator_* gauges from metrics.go (no new metrics, no go.mod changes). Env-overridable endpoints declared inline in each fetcher matching solana.go style. Shortcuts (uptime=100% for Cardano/Sui/Cosmos, hardcoded community_tax=2%, base 8.5% for Avalanche) documented in code.

---------

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

* rpc-capabilities: add 4 keyless providers (ThirdWeb Blast Gateway.fm bloXroute) (#1238)

Broadens the keyless cohort for benches 010 (rpc-capabilities), 083
(rpc-reliability) and the per-chain RPC benches. All 4 providers
verified live returning valid eth_getBlockByNumber on their
respective chains before inclusion.

New (provider, chain) cells:
  ThirdWeb    8 chains (ETH POL ARB OP BASE BSC AVAX LINEA)
  Blast API   4 chains (ETH ARB BASE BSC) — POL OP AVAX LINEA are 403 paid tier
  bloXroute   3 chains (ETH BASE BSC)
  Gateway.fm  1 chain  (ETH)

Total = 16 new cells. Metrics auto-flow into bench 083 (rank_matrix
groups by provider+chain) and the per-chain leaderboards after the
Railway rebuild picks up main.

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

* ws-head-latency: add Onfinality Ethereum to keyless cohort (#1241)

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

* drop oracle-freshness (082) spec + sync REMOVED with main state (#1245)

Oracle-freshness deleted from the site: apples vs oranges (Chainlink push vs Pyth/RedStone pull measure different things), splitting into pull/push sub-benches would leave each with 1-2 providers, redundant with bench 025 oracle-deviation which measures the same feeds via CEX deviation with a more actionable signal.

Slug kept in REMOVED_BENCH_SLUGS as 410 Gone. Harness code in oracle-deviation retained (metrics still emit but no site reader).

Also syncs dev's REMOVED_BENCH_SLUGS with main: removes tokenized-stock-arb-latency + rpc-reliability (shipped to prod earlier today) so dev matches main state.

Cache keys bumped bench-unfiltered-v34 + all-benchmarks-v37.

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

* SEO/E-E-A-T pack: /team page, Person schema, 23 positioning, 8 expert_take (#1249)

Adds a broad SEO and GEO upgrade landing three surfaces at once:

- /team page with Florent Tapponnier as Maintainer, sameAs GitHub +
  LinkedIn, Person JSON-LD wired into Organization.founder and into
  every bench StatisticalReport.contributor + TechArticle.author +
  reviewedBy so YMYL crypto queries see a named human accountable.
- 23 positioning fields on alternatives specs (200 words each,
  factual, sourced from official docs, fact-checked via WebFetch).
  Cuts the shared-template surface from ~65 percent to ~30 percent
  across pages that reference the same benchmark.
- 8 expert_take fields on answers specs (150-200 words each,
  structural commentary that stays valid regardless of who currently
  leads the leaderboard) with QAPage schema so the acceptedAnswer
  (Org, auto) and suggestedAnswer (Person, expert_take) both reach
  Google + AI Overviews.
- Byline on answers pages when expert_take is present, linking to
  /team, hidden when dataPending so editorial context never renders
  next to an empty leaderboard.
- noindex on awaiting benches so Prom outages do not surface thin
  placeholder pages to crawlers.
- Optional Zod fields positioning + expert_take + expert_take_reviewed
  with noAiDashes refine so future drafts stay in policy.
- Sitemap prebuild script emits real git-commit timestamps for the
  8 editorial pages so lastmod stops resetting to build time on every
  unrelated Vercel deploy.
- scripts/editorial-check.sh lints the two new fields for em/en
  dashes, marketing adjectives, pronouns, year references.

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

* alternatives: render positioning + clean paragraph line-wrap (#1250)

Two fixes on top of #1249:

1. Add a Positioning block on /alternatives/[slug]/page.tsx that
   renders the newly-added field. Phase 1 added the Zod schema
   field but never wired the UI, so the content sat in YAMLs
   invisible on staging.

2. Rewrite the 23 positioning + 8 expert_take YAML blocks as a
   single line per paragraph. Previous version hard-wrapped at
   180 chars, which YAML literal (|) preserves as newlines,
   splitting words mid-token in the rendered HTML.

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

* hotfix: strip glued expert_take_reviewed from status line (#1251)

* alternatives: render positioning + clean paragraph line-wrap

Two fixes on top of #1249:

1. Add a Positioning block on /alternatives/[slug]/page.tsx that
   renders the newly-added field. Phase 1 added the Zod schema
   field but never wired the UI, so the content sat in YAMLs
   invisible on staging.

2. Rewrite the 23 positioning + 8 expert_take YAML blocks as a
   single line per paragraph. Previous version hard-wrapped at
   180 chars, which YAML literal (|) preserves as newlines,
   splitting words mid-token in the rendered HTML.

* fix: strip glued expert_take_reviewed from status line

The previous rewrite regex dropped the newline before existing
expert_take_reviewed values without adding a proper break, so
'status: live' and 'expert_take_reviewed: ...' ended up on the
same line, failing YAML parse and killing the build export step.

Also collapsed duplicate expert_take_reviewed lines that landed
during the double-run of the rewrite script.

---------

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

* hotfix: strip glued status line in 8 answer YAMLs (#1252)

Fix that landed in a prior branch was lost during a squash-merge
sequence. Reapplying directly off dev to guarantee it lands.

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

* answers: drop visible byline on expert_take blocks (#1253)

The visible "By Florent Tapponnier, reviewed <date>" byline
implied authorship of every expert_take paragraph and dated the
review with a fabricated timestamp, both editorially fragile.

Keep the E-E-A-T signal at the schema layer only: Person JSON-LD
on Organization.founder, StatisticalReport.contributor, TechArticle
author + reviewedBy, and QAPage suggestedAnswer.author. Search
engines see the human attribution where it counts; readers see the
editorial commentary without a personal claim on every crypto
opinion the site publishes. Removes the unused Byline component too.

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

* drop bench 080 tokenized-stock-arb-latency (Rob Chain pools too illiquid) (#1254)

5 of 11 tickers had frozen pool prices (no swap activity). Remaining 6
had p99 pinned at the 32min histogram cap because arbs rarely close
within the settle window. Robinhood Chain launched Mar 2026, tokenized
equity market still too young for a meaningful arb-latency bench.

Removed:
  * benchmarks/tokenized-stock-arb-latency.yml
  * harnesses/tokenized-stock-peg/cmd/script/arb_latency.go
  * arb_tracker calls + closeStaleOnStateChange in main.go

Kept: bench 077 tokenized-stock-peg + 079 weekend-drift (static peg
measurements) stay live on prod as the honest signal for this cluster.
Slug in REMOVED_BENCH_SLUGS for 410 Gone. Cache keys bumped.

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

* jsonld: add 'name' to TechArticle nodes for Google Rich Results validation (#1257)

GSC flagged 'Missing field name' on aggregator-head-lag JSON-LD (and by extension every bench slug page + every per-chain page + the methodology hub — all TechArticle nodes shipped only 'headline'). Schema.org Article inherits 'name' from CreativeWork; Google's Rich Results validator treats it as required even when 'headline' is present. Add 'name' alongside 'headline' with the same value on all three affected builders.

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

* share-card: propagate region/kind/venue filters + compose dynamic title (#1260)

Feedback from Maxime: exported images do not adapt to the active
filters on the bench page. A share-card exported from
/benchmarks/rpc-capabilities?chain=ethereum&region=sgp used to render
just 'Fastest free public RPC ...' with cross-chain data; now the
loader picks up the region filter and the title reads 'Fastest free
public RPC ... Ethereum · Singapore' with the correctly scoped data.

Change:
  * share-card route reads region, kind, venue URL params (symmetric
    with the existing chain param). Each maps to a spec dimension and
    forwards to the materialize loader so the data is filtered too,
    not just the title.
  * A composed contextParts string is built from the resolved dimension
    labels and appended to the aggregate title. Empty when no filter
    is active, no trailing separator on the default view.
  * The composed title is overlaid onto the benchmark passed to each
    render (spread with {...b, title: displayTitle}) so all 5 templates
    (ranking, leaderboard, snapshot, headline, compare) pick it up
    without any render-signature changes.
  * share-section + share-section-modal read live URL params for
    region/kind/venue in addition to chain when composing the card URL,
    so a modal opened from a scoped bench page passes the same scope
    to the exported PNG.

Follow-up (not in this PR): view / range / topN selectors, per-bench
UI in the modal to expose the dimension pickers explicitly. This PR
is the plumbing; the modal UX for exposing new pickers is next.

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

* Revert "share-card: propagate region/kind/venue filters + compose dynamic title (#1260)" (#1261)

This reverts commit b0ce3ef564fe4ca2aca905245c0d4dd1c6373054.

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

* Revert "Revert "share-card: propagate region/kind/venue filters + compose dynamic title (#1260)" (#1261)" (#1263)

This reverts commit d7f0fcbbbb6ad0db967c285fd23f99ca76b066f5.

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

* aggregator-head-lag: peer-activity clause (on/chain,region) fixes pool-silent false positives (#1265)

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

* share-card: filter negative p50 in sortByP50 (fixes HTTP 500 on RPC benches) (#1266)

Commit c03c599 relaxed the sortByP50 filter from `p50 > 0` to `p50 !== 0` to support the negative-p50 'warming up' sentinel introduced for funding benches on the /products page. The relaxation leaked into the share-card render path, where negatives crash Satori: negative p50 values fed to Math.max produce a negative maxP50, which inverts every bar height ratio and blows up the JSX with an opaque 'Spread syntax' error mid-render.

Symptoms observed: /benchmarks/{rpc-capabilities,polkadot-rpc,ws-head-latency-ethereum,evm-block-builders}/share-card?template=ranking all returning HTTP 500. Working benches (network-coverage, perp-fees, oracle-deviation) had no negative sentinel values in their data.

Fix restores `p50 > 0` in the share-card filter. The negative sentinel handling stays on the /products page where it was designed to render (as 'warming up'). Share-card treats negatives as noise and drops them, same as zeros.

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

* share-modal: explicit dimension pickers (chain / region / kind / venue) (#1267)

Feedback from Maxime: user needs to be able to pick chain/region/kind/venue from within the share modal, not just inherit whatever the URL had when opening. Also the composed card title must reflect the pick in real-time.

Adds a DimensionRow chip component rendered above the template tabs. One row per dimension the bench declares (chainDims / regionDims / kindDims / venueDims). Each row: 'Label' + 'All' chip + one chip per option. Clicking a chip toggles state; clicking the active pick clears back to All.

State is seeded from the live URL on mount so the modal opened from a scoped bench page starts on the same scope (unchanged behaviour). User can override in-modal by clicking a different chip; state updates re-render the card preview with the new filter forwarded to the loader.

The cardSrc URL builder now reads from picker state instead of the live URL - previous implementation re-read the URL every render which meant the modal's own state changes couldn't propagate. Same URL param names on the share-card route: no server-side change needed.

Bench without dimensions: block hidden entirely, modal looks unchanged.

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

* aggregator-head-lag: ws_connected guard filters out harness-reconnect false positives (#1269)

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

* hotfix(share-card): revert region/kind/venue passthrough + title overlay (#1270)

The region/kind/venue param plumbing (added via #1260 → reverted via
#1261 → re-added via #1263) and the display-title overlay via
{...b, title: displayTitle} still cause HTTP 500 on rpc-capabilities
share-card and other benches with dimensions. My sortByP50 fix
(#1266) turned out not to be the actual root cause; the runtime error
persists after that fix + the modal picker PR (#1267).

Reverts the route.tsx changes only:
  - Restore original getBenchmark({chain}) call (no region/kind/venue
    forwarding)
  - Restore original switch that passes plain benchmark/filteredSafe
    to renders (no {...b, title: displayTitle} spread)
  - chainLabel still passed through (unchanged from pre-Maxime-feedback)

Keeps the modal DimensionRow pickers (#1267) intact for a future
retry once the true root cause is found. They currently look for
matching URL params but the server-side won't act on them until the
route.tsx re-adds forwarding safely.

Bug still open on rpc-capabilities/polkadot-rpc/ws-head-latency-*/
evm-block-builders share-cards, but now user-visible functionality
(chain filter, existing templates on non-broken benches) is fully
restored to pre-Maxime-feedback behaviour.

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

* aggregator-head-lag: strip ws_connected clause from geckoterminal (REST-only, no ws metric) (#1272)

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

* share-card: fix HTTP 500 on RPC benches (Satori AVIF/WebP + CSS var crashes) (#1273)

Two independent bugs causing pre-existing HTTP 500 on rpc-capabilities,
polkadot-rpc, ws-head-latency-*, evm-block-builders share-card
endpoints. Reproduced locally with bun dev + shimmed try/catch to
unmask the mid-stream Satori errors.

Bug 1: Satori (the JSX-to-SVG renderer next/og uses) only decodes
PNG / JPEG / SVG. Feed it AVIF or WebP bytes and it throws
'TypeError: u2 is not iterable' mid-render, surfacing as opaque
HTTP 500. publicnode.avif, drpc.webp, lava.webp all trigger this on
the RPC benches. Fix: skip AVIF/WebP extensions in
getProviderLogoDataUrl, fall back to the initials chip.

Bug 2: chipBackground / chipTextColor from src/lib/brand.ts return
CSS var references (e.g. 'var(--color-ink-soft)') when the provider
has no registered brand color. Satori evaluates var(...) to the
CSS-wide 'initial' keyword and rejects 'background: initial',
crashing the whole PNG. evm-block-builders is a full-cohort case
(Titan, BuilderNet, Quasar, Eureka, Builder+, Vanilla — none have
brand colors or logos). Fix: resolv…
Flotapponnier added a commit that referenced this pull request Jul 25, 2026
…ligibility) (#1433)

* solana cohort: solana-official logo alias, leorpc logo + light chip, registry entries

* nft harness: keyless OpenSea mode, empty x-api-key header caused 401s (#1136)

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

* nft harness: delist rarible, free tier is 100 requests per month (#1137)

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

* robinhood chain: chains hub entry, chain-kpis, l2-block-time (HTTP poll mode), network-fees

* bridge-monitor: on-chain balance fallback + degraded flag

* bridge-monitor: self-healing metrics + slack notifiers

* bridge-monitor: auto-rebalancer, corrective transfer via cheapest bridge

* bridge-monitor: tier downgrade ladder + rebalancer wiring in pre-flight

* bridge-monitor: stuck-fund reaper, hourly stranded gauge + corrective transfer

* bridge-monitor: gas auto-top-up via LiFi same-chain swap, gated by default

* bridge-monitor: unit tests for refill route, ladder, stranded hours; fix tracker reset

* bridge-monitor: keyless dry-run support for reaper + RunDryRun

* bridge-monitor: comment style cleanup in pre-flight docs

* bridge-monitor: daily spend tracker keyed by UTC date, persisted; execution mutex primitive

DailySpentUSD only ever incremented and was zeroed by every restart, so a
crash-loop minted a fresh budget each time. The tracker resets exactly at
UTC midnight, persists (date, spent) to SPEND_STATE_PATH (default
./spend-state.json, not /tmp) and is mutex-guarded. Also declares the
package-level single-flight lock and the FAILED_TX_FEE_ESTIMATE_USD
(default 0.50) helper for gas bled by failed broadcasts.

* bridge-monitor: fix double-send on in-flight rebalance, slot-wide budget, single-flight lock

Fund-loss fixes from hostile review:
- a corrective transfer whose deposit broadcast but whose bridge status
  never resolved is now TERMINAL for the scheduler slot (funds are likely
  still in flight, a retry double-sends); only pre-broadcast failures may
  consume another attempt. executeOnBridge keeps TxHash on error paths so
  callers can tell the two apart.
- ONE corrective-transfer budget (max 2) per scheduler slot, shared across
  all downgrade-ladder rungs (was per rung: up to 6 transfers per slot).
- package-level execution mutex: scheduler slots own the wallets end to
  end, the reaper TryLocks and skips its tick instead of queueing.
- broadcasts that failed or timed out after getting a TxHash book a flat
  FAILED_TX_FEE_ESTIMATE_USD against the daily cap instead of bleeding
  unaccounted gas; all spend goes through the persisted UTC-day tracker.
- gas top-up now honors MaxDailySpendUSD before broadcasting.

* bridge-monitor: SIMULATE_BALANCES only honored in dry-run mode

The reaper (and any other consumer) used to accept simulated balances even
in production with broadcast-capable keys, so phantom numbers could
green-light real transfers. SimulateBalances now takes the execution mode
and returns nil outside dry-run; startup logs a warning when the flag is
set in a broadcast-capable mode.

* tx executor: preserve hash on ambiguous send errors, split dry-run spend state

* executor: add missing chain label, first real single-test panicked post-fill (#1141)

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

* bridge-monitor: point Mobula calls at demo-api.mobula.io (#1142)

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

* tokenized-stock-peg: bench 076, onchain tokenized equities vs Nasdaq reference (staging gated)

* spec: missing store blob for a live bench throws instead of caching the miss (bnb-rpc awaiting incident) (#1144)

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

* tsp: parse the flat spark shape (close/timestamp arrays), log empty parses

* tsp: drop invalid rank_matrix_query, 1m reference candles, fee-band + USDG methodology notes

* rwa: new category, tokenized-stock-peg moves in, 11 equity logos + registry pages

* rwa: xstocks-peg (077) + usdy-nav-basis (078), issuer label on the tsp metric family

* rwa: complete product pages, hood/qqq/coin/orca/pyth logos + dual-issuer descriptions

* registry: googl links to google.com, abc.xyz 403s aggressively

* cardano: cache koios epoch params 1h (public tier was exhausted); stellar: protocol-minimum 5 reserves

* fix(store+bridge-monitor): fail-fast on missing blob + sub-50ms histogram buckets (#1152)

* spec: missing store blob for a live bench throws instead of caching the miss (bnb-rpc awaiting incident)

* bridge-monitor: sub-50ms buckets + nanosecond precision on Observe

Histogram Buckets grew from [50, 100, 200, 500, 1000, 2000, 5000, 10000]
to [10, 25, 50, 100, 200, 500, 1000, 2000, 5000, 10000]. Near Intents
on solver-cached corridors (HyperCore in particular) returns via a
1Click coordinator fast path in <50ms, and the previous scheme lumped
every one of those observations into (0, 50] so histogram_quantile
linearly interpolated to ~25ms regardless of true value. The p50 shown
on /benchmarks/bridge-quote-latency?chain=HyperCore was therefore a
bucket floor, not a measurement. Sub-50 buckets let the real bimodality
surface.

Also swap Observe(float64(quoteLatency.Milliseconds())) for
Observe(float64(quoteLatency.Nanoseconds()) / 1e6) across all 6
per-bridge observers (across, debridge, lifi, mobula, nearintents,
relay). int64 Milliseconds() truncates every sub-1ms round-trip to 0,
same bug class as the RPC harness fix in PR #1128. Not a hot path here
(network RTT dominates), but keeps precision consistent with the new
low buckets and cheap to fix while we are already touching each file.

Bench YAML methodology + FAQ updated to reflect the new bucket set.

---------

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

* fix: 3 machine-readable surface bugs (citable unit scaling, stat filters, draft asOf) (#1154)

* fix: 3 machine-readable surface bugs surfaced by GEO audit

1. /api/citable/{date} unit scaling.
   Snapshot route emitted `value: 627` for `aggregator-head-lag` while the
   live /api/citable emitted `value: 0.63` on the same measurement. The
   live route already wraps with valueInDeclaredUnit(raw, b.unit) but the
   snapshot route was left with the raw internal-ms value. Wrap the same
   way; sub-1 s benches now render in the declared `s` unit on both routes.

2. /api/stat/{slug} silently ignored ?chain= / ?region= / ?kind= / ?venue=.
   getBenchmark accepted a filters object but the route never parsed the
   URL. A citer asking /api/stat/rpc-capabilities?chain=ethereum got the
   cross-chain aggregate. Parse the four dimension params, pass through
   to the loader; unknown values fall back to unfiltered inside the
   loader. Also widen getBenchmark options type to include kind and
   venue — the underlying loadBenchmark already supports both.

3. Draft benches leaked a fake freshness signal.
   Draft placeholder set lastRunAt to new Date for type safety
   (Benchmark.lastRunAt is non-null), and both citable routes passed it
   straight through as asOf. LLM crawlers treating asOf as ground truth
   would think a draft bench was measured every minute. Null asOf in the
   JSON when status is draft; the internal type stays intact.

* fix: propagate draft-asOf null-out to /api/stat, MCP, JSON-LD dateModified

The GEO audit review found the initial fix in commit f6f84b8 only covered
the two /api/citable JSON surfaces, but the same "draft benches spoof
freshness" class of bug lives on three more machine-readable channels
that LLM crawlers also key on:

- /api/stat/{slug} asOf field.
- MCP tool responses (list_benchmarks, get_benchmark, resource template,
  and the plain-text "Last sample" line).
- JSON-LD dateModified on the bench page, per-chain sub-page, answer
  page and alternatives page — Google, Bing, Perplexity all consume
  dateModified as a freshness ranking signal.

Centralize the guard as citableAsOf(b) in @/lib/citation, rewire every
call site. Draft benches now uniformly omit or null the freshness
timestamp; live benches unchanged. On the visible /answers page,
the "Data as of..." line and its <time dateTime> anchor also hide for
drafts rather than displaying the wall-clock placeholder.

Follow-up to PR #1154 (commit f6f84b8) covering the surfaces the review
agent flagged as still spoofing.

---------

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

* feat: /api/compare/{a}/{b} + openapi.json coverage (Wave 1 P0/P1) (#1156)

* feat: /api/compare/{a}/{b} + openapi.json coverage

Two GEO surfaces flagged by the audit as high-value gaps.

## /api/compare/{a}/{b}

Head-to-head comparison as a single citable payload. LLMs asked "Mobula
vs Codex head lag" would otherwise need two /api/stat calls plus their
own delta reasoning; this endpoint returns one flat structure with per
benchmark p50 for each side, the winner on the metric's
higher-is-better convention, and the absolute delta.

The intersection logic mirrors src/lib/related-providers.ts and the
HTML /compare/{slug} page: shared benchmark = both providers appear in
it. Intentionally returns flat leader-p50 only (no chain/region
breakdowns, no cache-heavy fan out) so the agent tool call stays fast.

## openapi.json coverage

Added /api/citable/{date} (was missing despite being a real API
endpoint) and /api/compare/{a}/{b} to the OpenAPI 3.1 spec. Also
documented the dimension query params (chain/region/kind/venue) on
/api/stat/{slug} that shipped in the previous PR but were undocumented.
Added Compare schema; extended Stat schema with `filters` field.

/llms.txt and /llms-full.txt stay outside the OpenAPI surface — they
are text files not RPC endpoints, and OpenAPI consumers do not handle
plain-text specs well anyway.

## citableAsOf helper widened

Type signature relaxed from full Benchmark to
`Pick<Benchmark, "status" | "lastRunAt">` so the slim
ProviderAppearance.benchmark used by the new compare route can pass
through without a downcast. No behavior change on existing callers.

## Notes on items NOT shipped in this batch

- Cache-Control on /llms.txt / /api/citable / /api/llm-context: the
  audit report showed `cache-control: public` and inferred no CDN
  caching, but the source already sets
  `public, s-maxage=300, stale-while-revalidate=900`. Vercel strips the
  `s-maxage` directive from the client-visible header while honoring it
  at the edge; `x-vercel-cache: HIT` with `age: 256` confirms the CDN
  cache is active. Not a real bug.
- .bib / .ris file MIME endpoints: string versions already ship inside
  the /api/stat JSON via cite.bibtex / cite.apa. Marginal upside for
  Zotero-style one-click imports; downgraded to P2.
- /api/stat/{slug}/sparkline.svg: sparkline data already ships as a
  float array in /api/stat and the OG image at /api/og/{slug} covers
  the "embed a chart in an answer" case. P2.
- JSON-LD `Dataset.variableMeasured.value`: current schema emits bare
  strings; upgrading to PropertyValue objects would duplicate the
  measured value already carried by the StatisticalReport node's
  Observation.measuredValue. P2.

* openapi: add totalWins to Compare schema

---------

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

* feat: Wave 1 P2 batch — cite MIME, sparkline SVG, Dataset PropertyValue (#1158)

* feat: Wave 1 P2 batch — .bib/.ris/apa MIME, sparkline SVG, Dataset PropertyValue

Three GEO surface additions the audit downgraded to P2. Each one is
duplicative with an existing channel (cite.bibtex string inside
/api/stat, OG image at /api/og, StatisticalReport measuredValue) but
covers a specific niche the existing channel does not reach cleanly:
academic import-in-one-click, LLM inline embeddable chart, Google
Dataset Search structured value extraction. Ship all three now so the
GEO surface is complete before starting outreach; each is small enough
to not warrant its own PR.

## /api/cite/{slug}/{format}

Per-bench citation with the correct MIME type. Four formats:
  - bib -> application/x-bibtex (Zotero, LaTeX)
  - ris -> application/x-research-info-systems (EndNote, Mendeley)
  - apa -> text/plain (ready-to-paste APA string)
  - txt -> text/plain (ready-to-paste plain attribution)

Content is the same as the cite.* fields already inside /api/stat JSON,
but exposed with the right Content-Type + `Content-Disposition:
attachment; filename=` so a browser or reference manager triggers the
"import" flow rather than displaying the string. Added RIS to CiteBundle
in src/lib/citation.ts (TY GEN neutral fallback, AU / TI / PY / UR / Y2
/ ER fields, CRLF line endings per RFC convention).

## /api/sparkline/{slug}

Standalone SVG sparkline of the current leader's 24 h series. Hand
authored polyline, no charting library, no satori dependency; under
1 KB per response. Query params: `w` (40..1200, default 240), `h`
(20..400, default 60), `theme=light|dark`. Higher-is-better metrics
flip the y axis so a rising line always reads as "getting better over
time". Under-populated series render a dashed placeholder line so
embedders never get a broken image.

Cases this reaches that /api/og and the raw JSON sparkline array do
not: img/svg inline embeds in Perplexity Pages, ChatGPT answers, dev.to
posts, Notion pages. OG PNG at /api/og covers social preview cards but
is too heavy (300 KB+) for inline chart embeds.

## Dataset.variableMeasured -> PropertyValue

Bare-string array in Dataset JSON-LD upgraded to include structured
PropertyValue objects when the current leader has real numeric
aggregates. Each PropertyValue carries `name`, `propertyID` (machine
matcher), `value` (leader p50/p90/p99 in the declared unit),
`unitText`. sample_size stays a bare string — it labels an axis, not a
measurement. Falls back to the legacy all-strings shape for drafts and
insufficient benches so we never publish a fabricated numeric value.

New helper: buildBenchVariableMeasured() in src/lib/dataset-jsonld.ts.
BenchDatasetInput.variableMeasured now accepts
Array<string | VariableMeasuredValue>. `keywords` extraction pulls the
`.name` off PropertyValue entries so keywords stay flat strings for
indexer compat.

## openapi.json

Both new endpoints registered under paths, so agent frameworks
discovering the API through OpenAPI can call them.

* sparkline: XML-escape bench title + provider name in SVG

---------

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

* bridge-monitor: Mobula EIP-712 signature-confirm flow (fixes stuck deposits) (#1160)

* bridge-monitor: implement Mobula EIP-712 signature-confirm flow (fix stuck deposits)

* mobula sig: inject EIP712Domain type definition, API omits it

---------

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

* robinhood chain vague 1: weekend drift (079) + arb latency (080) + subsidy tracker (081)

* subsidy is a chain KPI, not a bench: kill standalone spec, add 3 cards to /chains/robinhood strip

* pm-api-latency: add Predexon (bench 6th provider), fresh key deployed on the 3 Railway probe regions

* usdy: 3-retry on Orca RPC to fix 40% success rate (#1168)

publicnode.com burst-limits the VPS on getAccountInfo, dropping
~60% of samples. Two retries with 500ms backoff bring hit rate to
>95% without switching endpoints. Also add commitment=confirmed
to reduce slot-miss null returns.

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

* bench vague 2: ship 081-085 (ws latency, oracle freshness, rpc reliability, indexer latency, block builders) (#1170)

* add bench 081 ws-head-latency: WS block-push lag harness + spec

* add bench 084 indexer-latency: HyperSync vs The Graph vs Alchemy time-to-queryable

* idx-latency: only classify error when every poll errored, else timeout

* bump bench-set cache keys for 084 (lockstep rule)

* add bench 082 oracle-freshness: staleness metrics in oracle-deviation harness (multi-chain chainlink, pyth publish_time fix, redstone), bump bench-set cache keys

* oracle-freshness: count unsupported mainnet feeds in freshness error counter

* add bench 085 evm-block-builders: ETH builder share via extraData + relay bidtraces + Arb/Base soft-conf lag, bump bench-set cache keys

* ebb: don't count historical relay payloads on first poll, retry arb calibration on zero support, reset WS backoff after stable session

* add bench 083 rpc-reliability: consensus lag, hash quorum, fixed-vector integrity in rpc-capabilities harness, bump bench-set cache keys

* bench 083: clamp_min rank_matrix_query so zero-incident providers keep cell ranks

* gate benches 081-085 behind staging until 48h clean data

* drop shipped RWA slugs from prod gate (live on main since #1162/#1167)

---------

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

* hyperliquid frontend page: add ProviderLogo in the hero next to the name

* hl frontends: add slash, topdog, markets-mobile logos + registry entries; dark-mode chip for slash + markets-mobile

* idx-latency: swap Alchemy for Mobula in the cohort (paid-plan key, bench races $0 pipelines only) (#1176)

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

* fix(bench): sample-size PromQL + leader reliability floor (#1175)

* fix(bench): sample-size PromQL + leader reliability floor

Two bench-correctness fixes surfaced by an audit of the oracle and
gas surfaces we plan to cite in outreach.

Bench 025 (oracle-deviation):
- sample_size was 'count(count by (source) (ocb_oracle_price))'
  which returns the count of distinct sources (3 or 4), not the
  count of samples over the window. The citable API therefore
  reported sampleSize=3-4 on every pair, at odds with the spec's
  'polled every 30s' claim. Rewrites the query to
  'sum(count_over_time(ocb_oracle_price[24h]))' across all 10
  pair entries so the number reflects actual poll volume.

Leader ranking:
- citationCandidates() now excludes providers with a measured
  success rate below 50 percent from the leader pool. Before this
  fix, gas-estimation showed Owlracle as #1 with successRate=6.48
  and a p50 gap of 0.001 gwei: technically accurate on the rare
  successful poll but useless as a leader claim. Falls back to the
  full live pool when every provider is under the floor so a
  totally degraded bench still surfaces a best-of-bad-options
  leader rather than vanishing. Freshness benches without a
  success query default to 100 percent in the loader so the guard
  is inert there.

Adds src/lib/citation.test.ts with 5 tests covering the Owlracle
scenario, higher-is-better ranking, and the degraded-pool
fallback.

* fix(bench): propagate reliability floor to rankings + template + hub

Follow-up on the previous commit. Review flagged that filtering the
leader without filtering the ranking arrays leaves self-contradictory
JSON: /api/stat can report leader=Etherscan while rankings[0]=Owlracle
on the same document.

Exports citationCandidates and adds rankedCandidates helper that
returns the same eligible pool sorted by the bench's higher_is_better
direction. leader() and fieldValue() now share it.

Wires the shared helper into every surface that emits a rankings-like
list or a best-of claim:

- src/app/api/stat/[slug]: rankings array now sorted from
  rankedCandidates so rankings[0].slug always equals leader.slug on
  the same JSON.
- src/app/api/llm-context: the Rankings numbered list under each
  bench matches the Headline sentence above it.
- src/app/api/mcp/[transport]: get_benchmark tool response and the
  openchainbench://benchmark/{slug} resource both share the pool.
- src/lib/bench-template: {{best_name}} / {{best_p50}} / {{worst_name}}
  tokens in bench copy resolve against the same pool so a bench body
  never crowns a provider its own headline excludes. Per-slug tokens
  {{p50:some-slug}} still resolve against the raw live pool so unknown
  tokens still fall through.
- src/lib/providers: rankProviders (drives /products hub wins count
  and per-chain leadership chips) now runs on the reliable pool.

Adds a consistency test locking that rankedCandidates[0].slug always
matches leader().slug on the same bench. Total: 6 tests in
citation.test.ts, 95/95 pass across src/.

Uses (r.successRate ?? 100) for the numeric guard to future-proof
against ProviderResult shapes that skip the field. Load path still
defaults to 100 (materialize/load.ts:748) so this is inert today, but
the extra ?? removes the fragility.

/api/compare/[a]/[b] is intentionally left unfiltered: the user
picks the two providers explicitly, so the head-to-head is a
requested comparison rather than a leader claim. Applying the floor
there would drop rows the user explicitly asked to see.

* fix(hub-card): honor citation leader in the hub grid headline

Follow-up review flagged that benchmark-card.tsx re-sorted b.results
locally and picked results[0], so a bench where citation demotes the
raw-p50 winner (e.g. Owlracle on gas-estimation) would show the
demoted provider on the hub grid while the bench page names the
correct leader. Two documents contradicting each other in one click.

Adds leaderSlug: string | null to BenchmarkCardData and populates it
at the projection boundary via leader(b), which already applies the
reliability + insufficient-sample filter. The card picks the leader
by matching that slug in the projected results, and falls back to the
raw best when the projection returned no leader (draft, insufficient,
or all providers filtered out) so a card still renders a headline
value in every state.

Kept the local sort so the fallback path and any downstream chip
using sorted[0].name keeps working.

Test suite unchanged: 95/95 pass across src/. No test needed since
this is a projection-boundary wire-up.

---------

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

* fix(registry): remove duplicate topdog entry (#1177)

Commit 349b786 (hl frontends: add slash, topdog, markets-mobile logos +
registry entries) added a 'topdog' entry at line 313 without noticing a
pre-existing 'topdog' entry at line 1061, breaking staging build with a
duplicate object literal type error.

Keeps the newer entry (Telegram bot at t.me/topdog_trade_bot, HL
frontend context) since it was the one intentionally added for the
Hyperliquid frontends leaderboard chip work. Drops the older Discord
signal bot entry (topdog.gg): the bench spec at
benchmarks/hyperliquid-frontends.yml has slug: topdog with name TopDog
and matches the newer, HL-native product.

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

* vague2 QA: unit sec for true-seconds benches (fixes 0.0s heros), soften 080 findings, drop dead 085 builder rows, 083 expected_n 40 (#1178)

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

* add bench 086 polkadot-rpc: first Substrate chain in the RPC cluster (#1179)

Extends rpc-capabilities to Polkadot, the first non-EVM, non-Solana
chain in the cluster. Substrate JSON-RPC via chain_getHeader (returns
hex-encoded block number matching EVM's eth_getBlockByNumber shape),
staleness by relay-block gap (40 blocks, ~4 min at Polkadot's ~6 s
cadence).

Harness changes:
- Chain.Kind gains "polkadot" alongside "solana" and default (EVM).
- New callSubstrateHeader: chain_getHeader POST, parses hex block
  number, returns parentHash as the observed hash. Same latency
  measurement + result classification as the EVM path.
- probeOne switches on Kind (was if solana / else). polkadot uses
  polkadotStaleBlockGap = 40. Consensus observation is skipped
  because chain_getHeader returns parentHash (N-1), not the current
  block hash (deriving current hash needs Blake2 over the SCALE-
  encoded header, out of scope).
- Archive loop is skipped for polkadot the same way as solana:
  Polkadot state accesses via state_getStorage keyed by Blake2 storage
  keys, no chain-agnostic depth analog for eth_getBalance-by-depth.

Providers at launch (3, live-verified on 2026-07-15 with four
consecutive chain_getHeader probes each):
- polkadot-official (Parity, rpc.polkadot.io)
- onfinality (public gateway, Web3 Foundation grantee)
- publicnode (Allnodes, extends its multi-chain footprint to
  Substrate keyless)

Audited and excluded from the launch cohort:
- 1RPC: filters chain_getHeader as "Not Allowed" on the privacy
  relay. Revisit if they expose a Substrate-friendly method set.
- Dwellir: 503 Service Unavailable during the audit sweep, revisit
  once the gateway stabilises.
- Ankr: paid Polkadot tier only.
- Chainstack: key-gated on Substrate.
- RadiumBlock: rejects without referral header.
- Grove / Thirdweb: invalid-chain response.

Spec (benchmarks/polkadot-rpc.yml, bench № 086):
- Same schema as moonbeam-rpc / avalanche-rpc etc. so the parent
  rpc-capabilities benchmark, chain cluster page and hub card
  render it identically.
- Methodology documents the Substrate specifics: probe method
  choice, staleness scaling, why no archive-depth or hash-quorum
  columns.
- SEO/finding tokens use the standard {{best_name}} / {{best_p50}}
  / {{p50:slug}} template surface, consistent with other -rpc
  benches.
- Provider tag copy explains OnFinality and Dwellir's Web3
  Foundation grantee status so the leaderboard reads correctly
  for someone who knows the Polkadot infra landscape.

Local checks:
- go build ./... clean.
- go test ./... clean.
- bun test src/ - 95/95 pass across 10 files, including the
  citation reliability floor and consistency tests added in #1175.

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

* product pages: 9 block-builder registry entries + verified logos, oracle logos (pyth/redstone) (#1181)

* products: registry entries + logos for bench 085 block builders

* oracle logos: redstone official logo + pyth/redstone manifest + registry entries

---------

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

* 085: add bobthebuilder provider row (real share, was metrics-only) (#1182)

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

* polkadot-rpc bench 086 + /chains/polkadot + Parity product (#1184)

* add bench 086 polkadot-rpc: first Substrate chain in the RPC cluster

Extends rpc-capabilities to Polkadot, the first non-EVM, non-Solana
chain in the cluster. Substrate JSON-RPC via chain_getHeader (returns
hex-encoded block number matching EVM's eth_getBlockByNumber shape),
staleness by relay-block gap (40 blocks, ~4 min at Polkadot's ~6 s
cadence).

Harness changes:
- Chain.Kind gains "polkadot" alongside "solana" and default (EVM).
- New callSubstrateHeader: chain_getHeader POST, parses hex block
  number, returns parentHash as the observed hash. Same latency
  measurement + result classification as the EVM path.
- probeOne switches on Kind (was if solana / else). polkadot uses
  polkadotStaleBlockGap = 40. Consensus observation is skipped
  because chain_getHeader returns parentHash (N-1), not the current
  block hash (deriving current hash needs Blake2 over the SCALE-
  encoded header, out of scope).
- Archive loop is skipped for polkadot the same way as solana:
  Polkadot state accesses via state_getStorage keyed by Blake2 storage
  keys, no chain-agnostic depth analog for eth_getBalance-by-depth.

Providers at launch (3, live-verified on 2026-07-15 with four
consecutive chain_getHeader probes each):
- polkadot-official (Parity, rpc.polkadot.io)
- onfinality (public gateway, Web3 Foundation grantee)
- publicnode (Allnodes, extends its multi-chain footprint to
  Substrate keyless)

Audited and excluded from the launch cohort:
- 1RPC: filters chain_getHeader as "Not Allowed" on the privacy
  relay. Revisit if they expose a Substrate-friendly method set.
- Dwellir: 503 Service Unavailable during the audit sweep, revisit
  once the gateway stabilises.
- Ankr: paid Polkadot tier only.
- Chainstack: key-gated on Substrate.
- RadiumBlock: rejects without referral header.
- Grove / Thirdweb: invalid-chain response.

Spec (benchmarks/polkadot-rpc.yml, bench № 086):
- Same schema as moonbeam-rpc / avalanche-rpc etc. so the parent
  rpc-capabilities benchmark, chain cluster page and hub card
  render it identically.
- Methodology documents the Substrate specifics: probe method
  choice, staleness scaling, why no archive-depth or hash-quorum
  columns.
- SEO/finding tokens use the standard {{best_name}} / {{best_p50}}
  / {{p50:slug}} template surface, consistent with other -rpc
  benches.
- Provider tag copy explains OnFinality and Dwellir's Web3
  Foundation grantee status so the leaderboard reads correctly
  for someone who knows the Polkadot infra landscape.

Local checks:
- go build ./... clean.
- go test ./... clean.
- bun test src/ - 95/95 pass across 10 files, including the
  citation reliability floor and consistency tests added in #1175.

* polkadot page + parity/onfinality/dwellir products

Wires the /chains/polkadot hub page, provider profiles, and logo
assets that surface once bench 086 polkadot-rpc starts feeding Prom.

- chains.ts: add Polkadot to CHAINS registry (L1, nativeSymbol DOT).
  Auto-populates /chains/polkadot once benches referencing chain="polkadot"
  land. Adaptive KPI strip fills in DefiLlama TVL, DEX volume and stables
  when the chain-kpis harness starts sourcing Polkadot alongside the
  other L1s.
- provider-registry.ts: three new entries for the Polkadot cohort.
    * parity: brand entry for Parity Technologies (Polkadot / Substrate
      implementation shop, operator of rpc.polkadot.io).
    * polkadot-official: endpoint-level entry describing the reference
      rpc.polkadot.io keyless endpoint; providers.ts aliases the slug
      to parity so the /products page collapses under the operator's
      brand.
    * dwellir: brand entry for Dwellir (Web3 Foundation grantee, keyless
      Substrate infra) — excluded from the 086 launch cohort due to a
      503 during the audit sweep but kept in the registry for the
      revisit and for other places their brand might appear.
- providers.ts: add polkadot-official → parity alias and canonical
  brand casings for polkadot / parity / onfinality / dwellir so the
  /products hub, badges, and OG images render the correct name
  without the title-case fallback butchering them.
- logo-manifest.ts: polkadot.png (chain hero), parity.png (product page
  logo), plus polkadot-official → parity slug alias so the endpoint
  chip on the bench page picks up Parity's mark automatically.

95/95 pass on bun test src/. Local build clean.

---------

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

* fix: dedup onfinality key in logo-manifest (already declared line 49) (#1186)

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

* store: last-known-good snapshot fallback (no more awaiting after first publish) (#1188)

publishSnapshot writes an lkg copy without TTL on every success;
readMaterialized falls back to it when the pointer/blob pair misses
(worker outage past blob TTL, DEL race, transient). dataAsOf keeps the
freshness badge honest about age. LKG write is best-effort and never
fails the publish.

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

* chain-kpis: add polkadot to registry (DOT native, stables via Asset Hub) (#1191)

Ships live KPIs on /chains/polkadot. DOT price and mcap flow through
Mobula. Stables card renders via DefiLlama Asset Hub aggregate.
TVL and DEX gauges stay unpublished (relay chain has zero DeFi:
parachain TVL lives under Acala, Moonbeam, Hydration).

Also guards defillama TVL publish to skip zero values so chains that
DefiLlama tracks by name but with an all zero series (Polkadot today)
hide the TVL card instead of rendering $0.

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

* live-feed status: Feed down pill + banner on aggregator-head-lag (#1193)

Short-window changes() probe per provider surfaces silent live feeds
that the 24h quantile aggregate hides (Codex WS blackout 08:23-09:04
UTC today moved the p50 by nothing but the whole feed was mute for
40min). Bench-level probe_ok gate suppresses badges when our harness
side is broken so a Railway crash cannot fake-flag every provider.

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

* series gaps: null the chart line when a feed goes silent (unless changes==0) (#1195)

Companion to feed-down banner: makes the 40min Codex blackout from
this morning (08:23-09:04 UTC) visible on the 24h chart instead of a
flat line covering the outage. Per-series match preserves the
aggregate line as long as one chain still receives events.

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

* add bench 087 ws-head-latency-base + 088 ws-head-latency-solana (#1196)

Per-chain siblings of bench 081 for the WebSocket head-lag cluster.
Same harness (harnesses/ws-head-latency), same race scoring, one
spec per chain to avoid mixing block cadences (Ethereum 12s vs Base
2s vs Solana 400ms) in a single leaderboard.

Base cohort: PublicNode + dRPC keyless endpoints. Tenderly does
not expose a keyless Base WebSocket path.

Solana cohort: PublicNode + Solana Foundation. slotSubscribe
replaces newHeads on the harness side; metric names are shared so
the queries are structurally identical to the EVM specs.

Data already flows via the Paris VPS deployment (chain label carries
base and solana). No harness change needed for this ship. Multi-region
is a follow-up on the parent bench.

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

* ws-head-latency: multi-region (harness region label + spec region tabs) (#1198)

* ws-head-latency: region label via ConstLabels + Railway config

Adds a static region ConstLabel to every ws-head-latency metric,
bound at process startup from OCB_REGION (preferred) or
RAILWAY_REPLICA_REGION (Railway auto-set), with eu-west fallback
matching the historical single-vantage Paris VPS deployment.

No observation site needs to change: the label is baked into every
counter, gauge and histogram at registration, so all existing
WithLabelValues(provider, chain) calls stay identical.

Adds railway.toml so Railway can build the harness from Dockerfile
with a /health check, matching the rpc-capabilities service layout.

Follow-up: create the Railway ws-head-latency service with 3
replicas (us-east, eu-west, sgp) and OCB_REGION set on each, then
decommission or leave the Paris VPS container as a 4th vantage.

* ws-head-latency specs: add region dimension tabs (081 + 087 + 088)

Adds `dimensions.region` block + `rank_matrix_query` on provider,
region + per-provider `regions:` sub-arrays with region-scoped
queries on the 3 ws-head-latency specs, following the polkadot-rpc
pattern.

Depends on the harness region label change (previous commit): every
metric now carries a region ConstLabel so the region-scoped
histogram queries return values as soon as multi-region replicas
publish. Until Railway 3-replica deploy lands, only region=eu-west
has data and the other tabs render empty; the All regions tab keeps
working because the aggregate query is unscoped by region.

---------

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

* ws-head-latency: align region env var with rpc-capabilities (REGION not OCB_REGION) (#1200) (#1201)

Every other multi-region harness on Railway (rpc-capabilities, aggregator-head-lag, evm-swap-quoting, pm-rate-limits, etc.) reads REGION from env. Making ws-head-latency read REGION too keeps the Railway service config uniform across the whole harness fleet.

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

* chart: red band overlay for null-bucket downtime windows (#1203)

Companion to #1195 (series null gaps). The gaps themselves render as
line breaks but a break can read as 'the chart missed a sample'. A
soft red column behind the break spans the exact outage window and
makes the reader understand it was a real feed-side silence. Per
line + per contiguous gap range, extended by one step so 1-bucket
outages stay visible instead of collapsing to a zero-width sliver.
Hidden for excluded (legend-toggled) providers.

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

* rename ws-head-latency -> ws-head-latency-ethereum for sibling slug parity (#1204)

081 becomes ws-head-latency-ethereum so the 3 per-chain benches in the head-lag cluster share the same shape: ws-head-latency-ethereum / -base / -solana. The old ws-head-latency slug is dropped (was staging-gated via REMOVED_BENCH_SLUGS, never live on prod, no external SEO to preserve).

Ungated on prod at the same time so the new slug ships alongside the base + solana siblings that are already live. Sibling text references updated to the new URL. Cache keys bench-unfiltered and all-benchmarks bumped so cached v29/v32 entries do not keep the old slug alive.

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

* network-coverage: drop coinstats (free tier credits exhausted) (#1206)

CoinStats /wallet/blockchains returns HTTP 406 "Credits limit reached" on every fetch: the free tier quota is spent below our 6h cadence and the provider never publishes a network count. Dropping until a paid key is provisioned.

Removes the coinstats.go adapter, the CoinStatsAPIKey config field, the coinstats entry in main.go, and the coinstats provider row + prose mentions in the spec. Cohort drops 6 -> 5 providers (GeckoTerminal, Codex, Mobula, CoinPaprika, Dune via Sim API).

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

* Revert "network-coverage: drop coinstats (free tier credits exhausted) (#1206)" (#1208)

This reverts commit d295f7168da981078164119e508ca9546ed69fa2.

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

* downtime bands: provider-colored tint + name/silent label + edge markers (#1210)

Feedback on #1203: the neutral red band read as 'something happened
here' without telling the reader WHO. Tint each band in the
provider's own color, add dashed edges at the outage boundaries, and
label 'PROVIDER · SILENT' above the band when it fits (with a caret
that survives on narrow single-bucket bands). Works uniformly for
Mobula/Codex/Gecko or any future provider with a live_activity
query.

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

* downtime bands: pill labels + vertical stacking on overlap (#1211)

Screenshot review: the small text-only label above the band was still
too subtle to read as 'this provider is down', and 3 simultaneous
provider outages produced overlapping unreadable labels stacked on
the same y. Replace with high-contrast rounded pills (white text on
provider color) that stack vertically when their X-ranges overlap.
Renders as '● NAME DOWN' in bold small caps so a reader gets the
event and the provider identity at a glance.

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

* tsp-arb-latency: lower trigger 50 -> 25 bps + credit sub-poll closures (#1212)

Bench 080 published only 7 samples across 3 of 11 assets in 24h at
50 bps trigger. Two contributing causes fixed together:

1) Trigger from 50 -> 25 bps. 50 bps in 90s is a headline move on
   Nasdaq (earnings, macro prints), not a steady-state event. 25 bps
   is above normal intra-minute noise on liquid stocks and captures
   real arb-worthy moves without collapsing the semantic.

2) Sub-poll convergence credited. When the reference moves past the
   trigger AND the pool is already within band on the same tick, the
   arb closed inside one poll interval. Previously dropped silently;
   now observed at pollInterval (60s) so the fast-arb tail on liquid
   pools stops disappearing from the histogram.

Convergence threshold stays at 20 bps: the "how close is the pool to
fair" question is unchanged, only trigger sensitivity moves. Spec
text (subtitle, methodology, FAQ) updated to 25 bps throughout.

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

* downtime pill: drop the leading dot indicator, center the label (#1213)

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

* downtime pill: 'DATA MISSING' wording + skip leading gaps (#1215)

Two fixes based on 30D view review:

- Leading gaps (Prom retention shorter than the visible window,
  harness started mid-range, provider added recently) rendered as
  a chart-wide band claiming every provider was down for the
  missing period. Skip runs that started before the first observed
  sample.

- Rename 'DOWN' to 'DATA MISSING'. A silent series can be the
  provider going down, our harness losing its WebSocket, or a Prom
  scrape failure — the pill stays neutral instead of blaming the
  provider.

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

* drop bench 084 indexer-latency (harness pruned, spec deleted) (#1218)

HyperSync and The Graph need paid keys for the sustained cadence the
bench expects; Mobula alone left the leaderboard single-provider and
the spec never made it out of REMOVED_BENCH_SLUGS gating. Simpler to
drop the whole cluster than to buy 2 SaaS subscriptions to prop up
a bench nobody has cited yet.

Removed:
  * benchmarks/indexer-latency.yml
  * harnesses/indexer-latency/ (whole directory)

Kept in REMOVED_BENCH_SLUGS as 410-gone so any indexed URL returns
Gone rather than 404. Cache keys bumped so cached v30/v33 entries
that still enumerate the slug age out on next revalidate.

VPS follow-up (separate ops): stop ocb-indexer-latency container,
remove from docker-compose.yml + prometheus.yml scrape config.

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

* video/render: strict Zod body schema before upstream forward (#1216)

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

* mcp: allowlist perp_funding_*, perp_venue_*, perp_execution_* prefixes (#1220)

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

* aggregator-head-lag: drop 'unless changes==0' from series queries (#1221)

Bug: the guard was designed for modern 15s-cadence data, where
changes([15m]) works cleanly. On backfilled historical data compacted
to ~1 sample/hour, changes([15m]) returns 0 even though the gauge is
healthy — the 30D view lost the first 8 days of data to false-drops
(all three providers marked DATA MISSING for a whole week).

Real-time silence is still surfaced by the live_activity scalar
(pill + banner + downtime band), which runs against fresh scrape
cadence and stays accurate. The chart series now shows every point
Prom has, flat during old outages instead of gapped.

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

* seo: 200 placeholders + sitemap threshold buffer (#1222)

- badge/[slug]/[provider]: serve pending SVG (200) instead of 404 when a provider is missing ranking data
- hyperliquid/[slug]: render lightweight placeholder page (200) instead of 307 to /hyperliquid when history blob is missing the builder
- compare adHocPairs: bump inclusion threshold to 3 (brand) / 4 (other) so a single bench flap can't cross both the sitemap gate and the page noindex gate

Fixes 3 recurring Ahrefs issues (broken badge images, noindex-in-sitemap on compare, 3xx-in-sitemap on hyperliquid) all rooted in the sitemap/page ISR race.

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

* aggregator-head-lag: restore outage detection with backfill-safe guard (#1224)

Follow-up to #1221. Dropping the guard entirely restored the 30D
range but silently hid today's Codex morning blackout on the 24H
view (frozen gauge value = flat line, indistinguishable from a
healthy quiet period).

Bring the guard back, gated on count_over_time > 5 samples in the
window. Modern data at 15s cadence carries ~60 samples per 15 min
so the gate fires the moment a WS goes silent. Backfilled data
compacted to ~1 sample/hour has count <= 2 in a 15m window so the
gate stays open and every point Prom kept is served — no more fake
wall-to-wall gap at the start of the 30D range.

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

* ws-head-latency: sub-ms histogram floor + keyed slots for base/solana (#1225)

Fix the leader-collapses-to-2.5ms artifact: histogram_quantile on a
[0,5ms] first bucket interpolates every winner's p50 to ~2.5ms
(chain-independent). Replace with a 0.5ms floor climbing to 10s so
sub-ms wins actually resolve on Solana (~400ms slots) and Base (2s).

Add keyed provider slots for Base (Alchemy/Infura/Chainstack/QuickNode)
and Solana (Alchemy/Chainstack/Helius/QuickNode) so the cohort can
extend beyond keyless once contributor keys are wired. Keyless tier
is saturated on all three chains (audit failed: Ankr, Blast, LlamaRPC,
BlockPI, 1RPC, NodeReal, Omniatech, base.org, extrnode, chainstack-
public — none accepted a keyless WSS handshake as of 2026-07).

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

* ship: ungate bench 085 evm-block-builders on prod (#1227)

Titan leads with sample=6973 in 24h, data healthy since the harness
ship. Removing from REMOVED_BENCH_SLUGS so the slug renders on prod
next revalidate. Cache keys bumped to bench-unfiltered-v32 +
all-benchmarks-v35 so cached entries do not keep dropping the slug
via the old prod gate.

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

* loader: propagate 24h series null-runs to 7d/30d (#1229)

Fine-grained outages (24h step = 20 min buckets) rarely align with
coarser evaluation timestamps (7d step = 2h, 30d step = 12h) so the
Prom query returns a healthy average and the reader sees the pill on
24H but nothing on 7D/30D. Post-process: for every contiguous null
run detected on s24, translate the time-ago range to bucket indices
on s7 and s30 and null every coarse bucket the run straddles.

Contiguity preserved so a single short outage doesn't split into two
adjacent pills after propagation. Same treatment applied to per-region
series.

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

* propagateNullsToCoarser: use absolute time-ago instead of window fraction (#1230)

Bug: the previous math scaled by the fine window fraction, which
projected a 7h-ago outage down to 40h-ago on the 7d grid (the pill
landed at -1.7d instead of -6h). Both grids share 'now' as their
right anchor and only the step sizes differ — take the fine idx's
absolute time-ago and divide by the coarse step to find the
covering bucket.

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

* tsp-arb-latency: drop 5 illiquid tickers (TSLA GOOGL AMZN MSFT SPY) (#1231)

Their Uniswap v4 pools on Robinhood Chain have no arb activity: pool spot stays frozen at the last swap value tick after tick while the Yahoo reference moves normally. Events open on ref triggers but roll over as still_open with zero latency samples. Cohort restricted to the 6 tickers with observable arb activity: NVDA AAPL PLTR META AMD MU.

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

* perp-fees harness: fix Paradex depth-skip, gains SOL slot, dYdX walk order, GMX Subsquid error surfacing (#1233)

- Paradex: cap fill ratio at 90% of visible book (depth=100). Tiers that eat through the tail now return book_too_thin and skip publishing instead of inflated 150-500 bps ghost values.
- gains: only cache USD-quoted pairs during pair discovery, widen scan 60->200. Root cause was a SOL/BTC pair overwriting the SOL/USD cache entry, publishing the wrong feeIndex and uniform 4.333 bps across ETH/BTC/SOL.
- dydx: sort v4-indexer level responses (asks ascending, bids descending) before walking the book. Insertion-order walk produced 1.7-2x inflated headline at 100k/1M tiers.
- gmx: prefix Subsquid errors, surface GraphQL errors[], fall back to unsuffixed positionFeeFactor if the impact-branch field is absent, and error out on zero factor. Prevents the silent-6bps-constant fallback path.

Verified: go build + go vet clean.

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

* downtime bands: opt-in via live_activity, skip on other benches (#1235)

Screenshot review: rpc-reliability rendered a 'LAVANODIES DATA MISSING'
pill even though its spec never declared live_activity — natural
nulls in its series24h (sparse scrapes, backfill edges) triggered
the DowntimeBands render. Gate the render on
benchmark.results.some(r => r.liveStatus != null), which is only set
when the spec declared live_activity. Same gate on
propagateNullsToCoarser so other benches' 7d/30d series stay
untouched.

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

* batch 2: MEV chips + rpc copy + Aster/edgeX + Cardano/Sui/Cosmos/Avax (#1236)

* site: provider_notes chip on ranking rows + rpc-capabilities copy fixes

- validator-yield now surfaces a per-provider chip (MEV inc./MEV excl./no MEV) beside each row so scan-readers can't accidentally compare Solana MEV-inclusive APR against Ethereum consensus-only APR without seeing the caveat
- New optional spec field provider_notes: {slug: string} plumbed end-to-end (Zod schema, TS type, materialize loader, spec overlay, ledger-table render)
- Chip renders only when the note is set, no visual change to any other bench
- rpc-capabilities: '10 EVM chains' copy corrected to 26 chains (24 EVM + Solana + Polkadot) across title/subtitle/seo_intro/abstract/methodology + FAQ
- rpc-capabilities disclaimer expanded to call out that the 'All chains' aggregate rewards single-chain foundation endpoints and to document Cloudflare-eth exclusion (returns -32603 on POST)

* perp-fees harness: add Aster + edgeX venues

- Aster (BSC): Binance-style depth walk against fapi.asterdex.com, hardcoded 5 bps taker (no public fee schedule API)
- edgeX (offchain CLOB): getDepth REST + hardcoded 3.8 bps taker per gitbook docs. Asset→contractId map declared inline (verify on first prod run; wrong ID surfaces as an orderbook fetch error, not a silent 0)
- Both venues probed for ETH/BTC/SOL at all 3 notional tiers ($1k/$100k/$1M) via applyBookTiers
- Closes the biggest coverage gap flagged by the perp-fees GEO audit (Aster is #2 perp DEX by 30d volume)

* validator-yield harness: add Cardano, Sui, Cosmos Hub, Avalanche

Closes the 'Highest staking yield' coverage gap: the bench previously ranked only Solana, Ethereum and Hyperliquid but AI answers to that question almost always include Cardano ($14B staked) and other top chains.

- Cardano (Koios API): top-50 pools by stake, net = ρ×(1-τ)×(1-margin). Simplified vs full a0/k/saturation formula but lands within ~50-100 bps of pooltool.io.
- Sui (public RPC suix_getValidatorsApy + suix_getLatestSuiSystemState): all active validators, apy is already net of commission per Sui docs.
- Cosmos Hub (LCD REST): top-200 bonded validators, net = inflation×(1-communityTax)/bondedRatio×(1-commission).
- Avalanche (P-Chain platform.getCurrentValidators): top-100 by stake, base 8.5% APR × uptime × (1 - delegationFee/100).

Reuses existing ocb_validator_* gauges from metrics.go (no new metrics, no go.mod changes). Env-overridable endpoints declared inline in each fetcher matching solana.go style. Shortcuts (uptime=100% for Cardano/Sui/Cosmos, hardcoded community_tax=2%, base 8.5% for Avalanche) documented in code.

---------

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

* rpc-capabilities: add 4 keyless providers (ThirdWeb Blast Gateway.fm bloXroute) (#1238)

Broadens the keyless cohort for benches 010 (rpc-capabilities), 083
(rpc-reliability) and the per-chain RPC benches. All 4 providers
verified live returning valid eth_getBlockByNumber on their
respective chains before inclusion.

New (provider, chain) cells:
  ThirdWeb    8 chains (ETH POL ARB OP BASE BSC AVAX LINEA)
  Blast API   4 chains (ETH ARB BASE BSC) — POL OP AVAX LINEA are 403 paid tier
  bloXroute   3 chains (ETH BASE BSC)
  Gateway.fm  1 chain  (ETH)

Total = 16 new cells. Metrics auto-flow into bench 083 (rank_matrix
groups by provider+chain) and the per-chain leaderboards after the
Railway rebuild picks up main.

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

* ws-head-latency: add Onfinality Ethereum to keyless cohort (#1241)

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

* drop oracle-freshness (082) spec + sync REMOVED with main state (#1245)

Oracle-freshness deleted from the site: apples vs oranges (Chainlink push vs Pyth/RedStone pull measure different things), splitting into pull/push sub-benches would leave each with 1-2 providers, redundant with bench 025 oracle-deviation which measures the same feeds via CEX deviation with a more actionable signal.

Slug kept in REMOVED_BENCH_SLUGS as 410 Gone. Harness code in oracle-deviation retained (metrics still emit but no site reader).

Also syncs dev's REMOVED_BENCH_SLUGS with main: removes tokenized-stock-arb-latency + rpc-reliability (shipped to prod earlier today) so dev matches main state.

Cache keys bumped bench-unfiltered-v34 + all-benchmarks-v37.

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

* SEO/E-E-A-T pack: /team page, Person schema, 23 positioning, 8 expert_take (#1249)

Adds a broad SEO and GEO upgrade landing three surfaces at once:

- /team page with Florent Tapponnier as Maintainer, sameAs GitHub +
  LinkedIn, Person JSON-LD wired into Organization.founder and into
  every bench StatisticalReport.contributor + TechArticle.author +
  reviewedBy so YMYL crypto queries see a named human accountable.
- 23 positioning fields on alternatives specs (200 words each,
  factual, sourced from official docs, fact-checked via WebFetch).
  Cuts the shared-template surface from ~65 percent to ~30 percent
  across pages that reference the same benchmark.
- 8 expert_take fields on answers specs (150-200 words each,
  structural commentary that stays valid regardless of who currently
  leads the leaderboard) with QAPage schema so the acceptedAnswer
  (Org, auto) and suggestedAnswer (Person, expert_take) both reach
  Google + AI Overviews.
- Byline on answers pages when expert_take is present, linking to
  /team, hidden when dataPending so editorial context never renders
  next to an empty leaderboard.
- noindex on awaiting benches so Prom outages do not surface thin
  placeholder pages to crawlers.
- Optional Zod fields positioning + expert_take + expert_take_reviewed
  with noAiDashes refine so future drafts stay in policy.
- Sitemap prebuild script emits real git-commit timestamps for the
  8 editorial pages so lastmod stops resetting to build time on every
  unrelated Vercel deploy.
- scripts/editorial-check.sh lints the two new fields for em/en
  dashes, marketing adjectives, pronouns, year references.

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

* alternatives: render positioning + clean paragraph line-wrap (#1250)

Two fixes on top of #1249:

1. Add a Positioning block on /alternatives/[slug]/page.tsx that
   renders the newly-added field. Phase 1 added the Zod schema
   field but never wired the UI, so the content sat in YAMLs
   invisible on staging.

2. Rewrite the 23 positioning + 8 expert_take YAML blocks as a
   single line per paragraph. Previous version hard-wrapped at
   180 chars, which YAML literal (|) preserves as newlines,
   splitting words mid-token in the rendered HTML.

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

* hotfix: strip glued expert_take_reviewed from status line (#1251)

* alternatives: render positioning + clean paragraph line-wrap

Two fixes on top of #1249:

1. Add a Positioning block on /alternatives/[slug]/page.tsx that
   renders the newly-added field. Phase 1 added the Zod schema
   field but never wired the UI, so the content sat in YAMLs
   invisible on staging.

2. Rewrite the 23 positioning + 8 expert_take YAML blocks as a
   single line per paragraph. Previous version hard-wrapped at
   180 chars, which YAML literal (|) preserves as newlines,
   splitting words mid-token in the rendered HTML.

* fix: strip glued expert_take_reviewed from status line

The previous rewrite regex dropped the newline before existing
expert_take_reviewed values without adding a proper break, so
'status: live' and 'expert_take_reviewed: ...' ended up on the
same line, failing YAML parse and killing the build export step.

Also collapsed duplicate expert_take_reviewed lines that landed
during the double-run of the rewrite script.

---------

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

* hotfix: strip glued status line in 8 answer YAMLs (#1252)

Fix that landed in a prior branch was lost during a squash-merge
sequence. Reapplying directly off dev to guarantee it lands.

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

* answers: drop visible byline on expert_take blocks (#1253)

The visible "By Florent Tapponnier, reviewed <date>" byline
implied authorship of every expert_take paragraph and dated the
review with a fabricated timestamp, both editorially fragile.

Keep the E-E-A-T signal at the schema layer only: Person JSON-LD
on Organization.founder, StatisticalReport.contributor, TechArticle
author + reviewedBy, and QAPage suggestedAnswer.author. Search
engines see the human attribution where it counts; readers see the
editorial commentary without a personal claim on every crypto
opinion the site publishes. Removes the unused Byline component too.

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

* drop bench 080 tokenized-stock-arb-latency (Rob Chain pools too illiquid) (#1254)

5 of 11 tickers had frozen pool prices (no swap activity). Remaining 6
had p99 pinned at the 32min histogram cap because arbs rarely close
within the settle window. Robinhood Chain launched Mar 2026, tokenized
equity market still too young for a meaningful arb-latency bench.

Removed:
  * benchmarks/tokenized-stock-arb-latency.yml
  * harnesses/tokenized-stock-peg/cmd/script/arb_latency.go
  * arb_tracker calls + closeStaleOnStateChange in main.go

Kept: bench 077 tokenized-stock-peg + 079 weekend-drift (static peg
measurements) stay live on prod as the honest signal for this cluster.
Slug in REMOVED_BENCH_SLUGS for 410 Gone. Cache keys bumped.

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

* jsonld: add 'name' to TechArticle nodes for Google Rich Results validation (#1257)

GSC flagged 'Missing field name' on aggregator-head-lag JSON-LD (and by extension every bench slug page + every per-chain page + the methodology hub — all TechArticle nodes shipped only 'headline'). Schema.org Article inherits 'name' from CreativeWork; Google's Rich Results validator treats it as required even when 'headline' is present. Add 'name' alongside 'headline' with the same value on all three affected builders.

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

* share-card: propagate region/kind/venue filters + compose dynamic title (#1260)

Feedback from Maxime: exported images do not adapt to the active
filters on the bench page. A share-card exported from
/benchmarks/rpc-capabilities?chain=ethereum&region=sgp used to render
just 'Fastest free public RPC ...' with cross-chain data; now the
loader picks up the region filter and the title reads 'Fastest free
public RPC ... Ethereum · Singapore' with the correctly scoped data.

Change:
  * share-card route reads region, kind, venue URL params (symmetric
    with the existing chain param). Each maps to a spec dimension and
    forwards to the materialize loader so the data is filtered too,
    not just the title.
  * A composed contextParts string is built from the resolved dimension
    labels and appended to the aggregate title. Empty when no filter
    is active, no trailing separator on the default view.
  * The composed title is overlaid onto the benchmark passed to each
    render (spread with {...b, title: displayTitle}) so all 5 templates
    (ranking, leaderboard, snapshot, headline, compare) pick it up
    without any render-signature changes.
  * share-section + share-section-modal read live URL params for
    region/kind/venue in addition to chain when composing the card URL,
    so a modal opened from a scoped bench page passes the same scope
    to the exported PNG.

Follow-up (not in this PR): view / range / topN selectors, per-bench
UI in the modal to expose the dimension pickers explicitly. This PR
is the plumbing; the modal UX for exposing new pickers is next.

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

* Revert "share-card: propagate region/kind/venue filters + compose dynamic title (#1260)" (#1261)

This reverts commit b0ce3ef564fe4ca2aca905245c0d4dd1c6373054.

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

* Revert "Revert "share-card: propagate region/kind/venue filters + compose dynamic title (#1260)" (#1261)" (#1263)

This reverts commit d7f0fcbbbb6ad0db967c285fd23f99ca76b066f5.

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

* aggregator-head-lag: peer-activity clause (on/chain,region) fixes pool-silent false positives (#1265)

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

* share-card: filter negative p50 in sortByP50 (fixes HTTP 500 on RPC benches) (#1266)

Commit c03c599 relaxed the sortByP50 filter from `p50 > 0` to `p50 !== 0` to support the negative-p50 'warming up' sentinel introduced for funding benches on the /products page. The relaxation leaked into the share-card render path, where negatives crash Satori: negative p50 values fed to Math.max produce a negative maxP50, which inverts every bar height ratio and blows up the JSX with an opaque 'Spread syntax' error mid-render.

Symptoms observed: /benchmarks/{rpc-capabilities,polkadot-rpc,ws-head-latency-ethereum,evm-block-builders}/share-card?template=ranking all returning HTTP 500. Working benches (network-coverage, perp-fees, oracle-deviation) had no negative sentinel values in their data.

Fix restores `p50 > 0` in the share-card filter. The negative sentinel handling stays on the /products page where it was designed to render (as 'warming up'). Share-card treats negatives as noise and drops them, same as zeros.

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

* share-modal: explicit dimension pickers (chain / region / kind / venue) (#1267)

Feedback from Maxime: user needs to be able to pick chain/region/kind/venue from within the share modal, not just inherit whatever the URL had when opening. Also the composed card title must reflect the pick in real-time.

Adds a DimensionRow chip component rendered above the template tabs. One row per dimension the bench declares (chainDims / regionDims / kindDims / venueDims). Each row: 'Label' + 'All' chip + one chip per option. Clicking a chip toggles state; clicking the active pick clears back to All.

State is seeded from the live URL on mount so the modal opened from a scoped bench page starts on the same scope (unchanged behaviour). User can override in-modal by clicking a different chip; state updates re-render the card preview with the new filter forwarded to the loader.

The cardSrc URL builder now reads from picker state instead of the live URL - previous implementation re-read the URL every render which meant the modal's own state changes couldn't propagate. Same URL param names on the share-card route: no server-side change needed.

Bench without dimensions: block hidden entirely, modal looks unchanged.

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

* aggregator-head-lag: ws_connected guard filters out harness-reconnect false positives (#1269)

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

* hotfix(share-card): revert region/kind/venue passthrough + title overlay (#1270)

The region/kind/venue param plumbing (added via #1260 → reverted via
#1261 → re-added via #1263) and the display-title overlay via
{...b, title: displayTitle} still cause HTTP 500 on rpc-capabilities
share-card and other benches with dimensions. My sortByP50 fix
(#1266) turned out not to be the actual root cause; the runtime error
persists after that fix + the modal picker PR (#1267).

Reverts the route.tsx changes only:
  - Restore original getBenchmark({chain}) call (no region/kind/venue
    forwarding)
  - Restore original switch that passes plain benchmark/filteredSafe
    to renders (no {...b, title: displayTitle} spread)
  - chainLabel still passed through (unchanged from pre-Maxime-feedback)

Keeps the modal DimensionRow pickers (#1267) intact for a future
retry once the true root cause is found. They currently look for
matching URL params but the server-side won't act on them until the
route.tsx re-adds forwarding safely.

Bug still open on rpc-capabilities/polkadot-rpc/ws-head-latency-*/
evm-block-builders share-cards, but now user-visible functionality
(chain filter, existing templates on non-broken benches) is fully
restored to pre-Maxime-feedback behaviour.

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

* aggregator-head-lag: strip ws_connected clause from geckoterminal (REST-only, no ws metric) (#1272)

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

* share-card: fix HTTP 500 on RPC benches (Satori AVIF/WebP + CSS var crashes) (#1273)

Two independent bugs causing pre-existing HTTP 500 on rpc-capabilities,
polkadot-rpc, ws-head-latency-*, evm-block-builders share-card
endpoints. Reproduced locally with bun dev + shimmed try/catch to
unmask the mid-stream Satori errors.

Bug 1: Satori (the JSX-to-SVG renderer next/og uses) only decodes
PNG / JPEG / SVG. Feed it AVIF or WebP bytes and it throws
'TypeError: u2 is not iterable' mid-render, surfacing as opaque
HTTP 500. publicnode.avif, drpc.webp, lava.webp all trigger this on
the RPC benches. Fix: skip AVIF/WebP extensions in
getProviderLogoDataUrl, fall back to the initials chip.

Bug 2: chipBackground / chipTextColor from src/lib/brand.ts return
CSS var references (e.g. 'var(--color-ink-soft)') when the provider
has no registered brand color. Satori evaluates var(...) to the
CSS-wide 'initial' keyword and rejects 'background: initial',
crashing the whole PNG. evm-block-builders is a full-cohort case
(Titan, BuilderNet, Quasar, Eureka, Builder+, Vanilla — none have
brand colors or logos). Fix: resolve var(..…
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