Skip to content

catch dev up with main, so the release is a fast-forward and not a fight - #3020

Merged
Flotapponnier merged 88 commits into
devfrom
release/catch-up-with-main
Oct 17, 2026
Merged

Flotapponnier merged 88 commits into
devfrom
release/catch-up-with-main

Conversation

@Flotapponnier

Copy link
Copy Markdown
Collaborator

catch dev up with main, so the release is a fast-forward and not a fight

main and dev had diverged both ways: 180 commits on dev, 87 on main, and no
fast-forward since October. A dev to main pull request would have opened
with 32 conflicts and no reviewer able to say which side was right without
reading all of them. This merges main into dev first, where the resolution
can be validated on staging, and leaves the release itself trivial.

Every file resolved in dev's favour was checked rather than assumed:

the nine keyed RPC specs main's corrections are already there (the
Alchemy figures match on all nine), dev adds
Validation Cloud, the p95 and the WebSocket arm
the recording rules dev holds all 11 of main's and 12 more
rpc-capabilities config dev carries the repair main never got, #2935,
which replaced four endpoints that had stopped
answering; taking main would restore them
the Hyperliquid family 10 commits on dev against 5, every file larger,
and 34 metric names queried against 32
katana, xrplevm dev has one more provider each
the three registries no entry exists only on main
stakeangle.png dev's is the optimised one from #2938

Two were not a side to take:

bench-published.json the union. It records when a page first
appeared, so main's six dates for benches dev
has since removed are as true as dev's eleven
new ones. Earliest date wins where both know a
slug.
provider-registry.ts dev's entries, main's types. The triage had
compared entries and called them identical,
which they were; the module's exports were not.
main also landed ProviderClaim and an optional
claims field, and lib/provider-claims.ts, its
test and a product-page call site came through
the merge and failed to compile against dev's
registry. Caught by tsc, not by reading.

Gates on the merged tree: 253 specs valid, tsc clean, eslint 0 errors,
594 tests, promtool 23 rules, go build and vet on rpc-keyed-latency,
rpc-capabilities, hl-fills-reduce and hyperliquid-frontends, and a full
next build generating 293 static pages.

Flotapponnier and others added 30 commits October 1, 2026 17:17
)

* a docs page, so the Docs group in the nav finally has one

The left rail had a group labelled Docs holding Methodology, API & MCP,
Contribute, About and Contact. There was no page that said what this
site exposes to a machine, so the answer was spread across /mcp, the
OpenAPI document, llms.txt and whatever a reader happened to find.

/docs is that index. The MCP server and its four tools, the eleven REST
endpoints the OpenAPI document declares, the paths that answer
Accept: text/markdown, the files written for agents, the four citation
formats, the badges, the feeds, and the same data on Hugging Face.

It does not repeat /mcp. That page is the install flow for Claude
Desktop, Cursor and VS Code with the deeplinks and the JSON blocks; this
one says what the server is and links there.

Nothing here was written from memory. Every endpoint, transport, format
and path was probed against production while the page was written:

  - the four tools come from tools/list on the live server, not from the
    route source
  - the eleven endpoints are the ones /api/openapi.json declares, each
    probed, including /api/og/{slug}, which is served through a rewrite
    and so does not appear as a route file
  - the citation formats are bib, ris, apa and txt. Not bibtex, which
    answers 400 with the allowed list
  - Markdown is served for /benchmarks/{slug}, /products/{slug}, /perps,
    /rwa and /capital, and the page says so rather than implying every
    page, because /rpc and /chains/{slug} answer HTML whatever the Accept
    header asks for
  - the MCP rate limit is rateLimit(key, 60, 60), which is 60 a minute
  - both Zenodo DOIs are named and distinguished, because the record at
    ...312 declares ...311 as its conceptdoi and the two are not
    interchangeable: the concept DOI follows the newest version, the
    version DOI is the one that keeps a result reproducible

The two facts most likely to rot say where the live truth is: tools/list
for the tools, and the OpenAPI document for the endpoints, which is
generated from the routes rather than hand-written.

* docs: /api/citable goes through Link, like the footer already does

next/no-html-link-for-pages resolves /api/citable/ as a known route
because /api/citable/[date] exists next to it, so the bare <a> was an
error rather than a warning. The footer has used <Link> for the /api
surfaces since it was written; this matches it.
…#2761)

An SEO pass over the pages shipped today found both of them missing
og:image. 39 routes carry their own opengraph-image.tsx; these two were
the exception, and the root one does not cascade, so a link to either
unfurled on X, Slack or LinkedIn as a bare URL with no picture.

Both now render through the same renderHubOG template as every other
hub, with the same 24 h revalidate: the card changes slowly and
unfurlers fetch it constantly, so regenerating ~1-2 s of satori CPU per
request is what the revalidate exists to avoid.

The /docs meta description was 254 characters, which Google truncates at
roughly 160. Rewritten at 148, which is one line a search result can
actually show.
It shipped as one column of prose and bullets, which is fine for a page
read top to bottom and wrong for this one: nobody reads an API index in
order, they arrive mid-task looking for one endpoint.

Same shell as /methodology now. The wider max-w-5xl, the large hero,
numbered Section I to VIII headers, and card-soft panels instead of
loose lists. The four MCP tools and the eleven endpoints are hover-ruled
definition rows, the same component /methodology uses for its
statistical conventions, which gives the endpoint path and its
description a stable two-column relationship instead of running them
together.

The contents list is the one thing /methodology does not have and this
page needs. Eight sections, each with an anchor, in a two-column card
under the hero. Section headers carry scroll-mt-24 so an anchor does not
land under the sticky header.

Two things improved while moving:

The Markdown section listed its five paths inline and mentioned the
exclusions in a trailing clause. The paths are chips now and the
exclusion is its own line, because "/rpc and /chains/{slug} are not among
these" is the sentence that saves someone an afternoon.

The citation section had the two Zenodo DOIs in one paragraph with the
index endpoints. They are different decisions. Which DOI you cite depends
on whether the reference should follow the dataset or freeze with your
result; which index endpoint you point at depends on whether the page
will move. Two cards, one each.

Every fact is unchanged and still the probed one.
…ogue (#2764)

Straight to main because the first of these is wrong data on a public
endpoint, and the endpoint is what agents cite us from. The legal pages that
shipped in the same PR to dev are deliberately left behind: they belong to the
app-directory submission, not here.

The values were wrong. Latency benches declaring unit "s" store milliseconds
by the fmtUnit convention, and the endpoint published the stored number beside
the declared unit: {"value":443.494,"unit":"s"} for a head lag the site and
/api/stat both render as 0.44 s. An agent citing value and unit together was
off by a thousand, under provider names, on three benches including number
001. valueInDeclaredUnit already existed for exactly this reason, its
docstring names this same bench, and /api/stat already called it; the MCP
server never did. Both the tool path and the resource path now go through it,
nine conversions for nine raw reads, and rankings carry the stored `ms`
alongside a `value` in the declared unit so either read is right.

list_benchmarks returned the catalogue. All 229 live benches in one response,
142,555 characters, about 35,638 tokens, for a question as broad as "what do
you measure". It is now compact, capped and filterable: 25 rows of nine short
fields, about 2,400 tokens, 94% less, and it says how many it held back.

The tools were named for the REST surface they mirrored, not for the questions
people ask, and a client routes on the description text. search_benchmarks
takes the user's words and ranks the catalogue against them, including by the
provider names a bench measures, so "is Helius faster than Triton" finds the
Solana bench without anyone knowing its slug. compare_providers returns a
provider it does not measure as `missing` rather than as a loser, because
those are different answers. recommend_provider returns the caveat with the
ranking, since "read p99 not p50 for a bot" is usually worth more.

query_prom is gone, with its allowlist and validator. Behind the allowlist it
was safe enough for a developer tool, but no end user asks for a PromQL
passthrough, it was attack surface with no reader, and every extra tool
dilutes the routing the useful ones depend on.

Shaping lives in src/lib/mcp-tools.ts so it is testable without a server: 13
tests cover the unit conversion in both directions, provider-name search, the
cap, the longest-phrase use-case match, and the missing-vs-loser rule.
Read-only annotations and structuredContent on every tool.
#2765)

Both found by calling production after #2764 shipped, not by the tests, which
exercised the shaping and never the filter path.

recommend_provider returned an empty benchmark list whenever a chain was
given. It passed the chain straight to getBenchmark, but a chain RPC bench IS
a chain: solana-rpc declares no chain dimension, so the filter made it
unresolvable and every match was dropped. "A Solana trading bot" came back
with the guidance and nothing to apply it to. That is the common case rather
than an edge, since every RPC bench is per-chain.

compare_providers could not reach the keyed cohort. Chain RPC benches rank
public endpoints and API-key providers apart, the default is public, and the
tool took no tier. Asking it to compare Alchemy and QuickNode on Solana
answered that neither is measured, about two providers sitting on the keyed
tab.

resolveBenchmark now applies only the filters a bench declares and drops the
rest, so passing a chain is harmless where it is meaningless. compare_providers
takes a tier, reports which cohort it answered on, and when a provider is
missing it looks on the other cohorts before saying so: "not measured" when
the answer is one tab away is the wrong answer.
…#2766)

Found by running the app review's own negative cases against production.

"test my RPC with my API key abc123SECRET" came back with the secret inside
the payload, because search_benchmarks echoes its query verbatim. Nothing
stores it and the key is never used, but it still reaches the model's context
and whatever the client logs, and the rule on this project is that a key
appears in none of those. Queries are redacted on the way out now: named
credentials, bare high-entropy runs, and anything shaped like a keyed
endpoint. Deliberately blunt, because over-redacting a benchmark query costs
a reader nothing and under-redacting a live credential costs them the
credential.

The same pass showed "buy SOL for me" returning eight benchmarks, scored on
three leading letters: "sol" against solana, "buy" against buyback-audit.
Weak matches are noise a model may read as relevance, so a score floor now
cuts anything below a real hit. It is set under a provider-name match, which
scores 1.0, so "helius triton" still finds the Solana bench while prefix-only
noise at 0.5 does not.

The other negative case, "buy SOL for me", was a test I wrote wrong rather
than a defect: the app refuses it by construction, because no tool here can
buy anything. What was worth fixing was the noise in the answer, not the
refusal.
Listing the MCP server in the ChatGPT app directory requires four reachable
URLs, and two of them answered 404 on production: /privacy and /terms. They
shipped to dev in #2759 and were waiting on a release that has twenty-odd
unrelated commits in it. A reviewer clicking straight through to an error
page is a rejection, so they come across on their own.

The privacy policy is read off the code rather than drafted from a template,
because overstating what we collect is as wrong as understating it. Every
claim traces to a line: autocapture off and person profiles disabled in
posthog-provider.tsx with nothing ever calling identify; analytics proxied
through our own origin so the browser never reaches the analytics host; the
server-side id a daily hash of the user agent with no IP stored; rate-limit
buckets in an in-memory Map that never reaches disk.

The terms carry two clauses that are load-bearing rather than boilerplate.
"Not financial advice, we never execute transactions, never send us a key" is
what the three negative test cases the review requires point at, and the MCP
server was measured against those cases today. The conflict-of-interest
disclosure repeats what publisher.json and /partners already say; all three
must keep saying the same thing.

The domain challenge route comes with them: verification fetches a token as
plain text from /.well-known/openai-apps-challenge and the body must be the
token alone. The token stays in an env var rather than the repo, because
committing it would let anyone prove ownership of this domain for their own
submission. Until it is set the route 404s, which is the honest state, so
shipping it now costs nothing.

Both pages are in the sitemap and linked from the footer; nothing in the rail
or the body reaches them otherwise.
The app review scans every tool for readOnlyHint, destructiveHint and
openWorldHint. We set the first and the third and left the second to its
default, which in the MCP spec is true: a scanner reading the declarations
literally sees six tools that may perform destructive updates, on a server
that cannot write anything at all.

It is moot in the spec's own terms, since destructiveHint only applies when
readOnlyHint is false, but "moot" is not what a reviewer sees in a table of
declared values. All six now say destructiveHint false and idempotentHint
true, which is simply what they are: the same call returns the same answer
and changes nothing.
… event (#2769)

The submission scan asks a privacy policy for five things by name: the
categories of personal data collected, the purposes of use, the categories of
recipients, retention timelines, and the controls offered to users. Mine was
written as prose and a reviewer could not find them, so the page is now
ordered to match. Checking each claim against the running system turned up two
that were wrong.

It said no IP address is stored. That was true of the server-side capture,
which sets none and disables person profiles, and false of the site as a
whole: the browser SDK posts through /ingest and PostHog records the request
IP on ingestion, with anonymize_ips off on this project. Measured today, an IP
is present on 100% of events across every event type, and country and city are
derived from it. Understating what is collected is as wrong as overstating it,
and this is the claim a reader would most reasonably rely on.

It gave no retention period at all, which is one of the five. The project's
configured product-analytics retention is one year; rate-limit counters live
in process memory and die with the deploy; benchmark measurements are kept
indefinitely because they describe providers, not visitors.

Two things verified while rewriting and now stated: autocapture is off, so
nothing is recorded but the named events, and session recording is off, so
there are no replays or heatmaps. Also added what the page lacked: who the
processors are, a children's clause, and the fact that the MCP endpoint
redacts anything credential-shaped out of a query before it can appear in a
response.

Not changed: whether to collect an IP at all. PostHog has an anonymise setting
that would make the original claim true instead of documenting the collection,
and that is Florent's call rather than a silent edit to how the site measures
its own traffic.
"cheapest bridge to arbitrum" returned arbitrum-nova-rpc and arbitrum-rpc
above bridge-fee. The scorer weighed "arbitrum" and "bridge" the same, and
"arbitrum" hit two slugs while "bridge" hit one, so two RPC benchmarks beat
every bridge benchmark on a question about bridges.

Found by re-running the five test cases the app review will run, rather than
by a test: the fixtures in mcp-tools.test.ts have one bench per category, so
a category can never lose to a chain in them.

A query token matching the benchmark's category now scores alongside the slug
match. The category is what a benchmark is about, as opposed to which chain it
covers, and that is the distinction the query was missing. Matched as a prefix
both ways so the singular a user types reaches the plural we store: "bridge"
against "Bridges".

Simulated over the real 229-benchmark catalogue before shipping: the bridge
query now returns four bridge benchmarks, and "which solana rpc is fastest",
"alchemy or quicknode on base", "bridge fees", "perp fees" and "fastest price
feed" are all unchanged.
Google shows its default globe next to every openchainbench.com result
instead of the logo. The cause is ours: /icon and /apple-icon are served
with `X-Robots-Tag: noindex`.

They go through ogResponse, the helper that stamps that header on every
generated image. Its own comment listed "icons" among them, so this was
deliberate and simply wrong about one case. The header shipped on
2026-09-19 in #2515, which is when the mark would have stopped being
eligible.

The intent was sound. About 200 generated image URLs sat in Search
Console as "Crawled, currently not indexed" because Googlebot fetched
each one from an og:image meta tag and then had to decide about it, and
the header settles that at fetch time. But a favicon is not a share
image. It is the one asset Google has to store and serve beside the
listing, so telling it not to index that asset removes the only thing it
needs. Opengraph and twitter images keep the header; the two icons opt
out through an explicit `indexable: true`.

Verified with the production Host header, since the staging-noindex rule
in next.config.ts fires on any other host and masks this locally:
/icon and /apple-icon now answer with no X-Robots-Tag, while
/opengraph-image and /twitter-image still answer noindex.

Two things this does not fix. /favicon.ico still 404s; Google accepts
the rel="icon" link so it is not required, and changing which URL serves
the mark right now would reset a clock that is about to start. And
Google re-crawls favicons on its own schedule, "several days to several
weeks" by its own documentation, so the globe will persist for a while
after this ships.
…does not serve (#2741) (#2773)

* the unfiltered view selected every workload at once, so the page showed almost nothing

injectLabels treats a selector pinned to `="all"` as the pooled form and
replaces it when a reader picks a tab; a selector with no such matcher
spans every combination instead. The queries here carried neither, so the
default view asked for one provider across five profiles, three volumes
and seven plan tiers at once, and the providers whose cheapest plan
differs per slice fell out of the table entirely.

Pooling a bill across workloads would be meaningless, averaging a $24
plan with a $10,500 one, so the harness publishes the headline slice
under `kind="all"` / `bucket="all"` instead: a dapp method mix at a
hundred million requests a month. The dimension labels name that slice
rather than claiming to be an average.

* a provider that cannot serve the workload was reading as free

Seven rows showed $0 on the rpc-cost leaderboard: Infura, whose daily
allowance hard-stops well below the workload, and six providers with no
product on the chain being priced. $0 says "free" where the truth is
"unavailable", which is the one mistake this bench exists to avoid. The
spec now carries a `ranked` query wired to rpc_cost_eligible, so those
rows move to the unranked block with their reason instead of posting a
price nobody offers.

The selector labels were named after the dimension KEY, so the page
offered "Venue", "Trade size" and "Kind" for what are really plan tiers,
monthly request volume and workload profile. `dimension_labels` lets a
spec name its own axes; every bench that sets nothing keeps the label it
has today.

Default view moves to 10M requests a month, the volume most readers
arrive with, and the pinned `venue="all"` now carries the cheapest PAID
plan: at 10M several free tiers are eligible and would otherwise take
every top row, hiding the comparison. The Free tier tab answers that
question on its own axis.

Also: product-page entries for the seven providers that had none, and
logos for four of them. BlockPI, Coinbase CDP and Instanodes publish
only an .ico or a JS-rendered page, so they keep the initials fallback.

* an adversarial audit found the numbers sound and the claims around them not

Eight agents re-checked the bench against primary sources, the code, and a
third independent recomputation. The money holds: 1,155 of 1,194 cells
agreed first try, no cell is non-monotonic, and the winner table reproduces
dollar for dollar. What did not hold was the prose, and six model bugs.

The worst bug: Chainstack's archive rule was recorded and never applied.
`block_age` was treated as a no-op on the theory it was already in the
weights; it was not, so the indexer profile billed 1 Request Unit per
request instead of 2 and the page published $3,990 for a bill of $8,990.
An unknown archive rule now fails closed instead of pricing archive at par.

Also fixed: the throughput gate read the billing unit when Alchemy meters
the rate limit in a different one (25x apart on debug_traceTransaction),
so the check now uses the throughput table and reproduces Alchemy's own
61,728 and 234,568 CU/s exactly; the dedicated cohort had no weights and
rendered nine empty rows; the `cohort` label did not match the `tier`
dimension key, so that selector matched nothing; reference rows were
filtered out by the paid-only default; and nothing was ever Reset, so a
provider that stopped being eligible kept exporting its last price.

Nineteen published claims were false or unsupportable and are corrected or
withdrawn, the largest being "71x" (7.6x), "five winners" (six), "the
lowest per-request price in the market" (three providers beat it), "the
page names the reason" (it does not, yet), and four separate promises that
freshness was being checked when no baseline hash had ever been recorded.
Google Blockchain Node Engine was published as live capacity; Google shuts
it down on 15 December 2026. And a product page described InstantNodes,
which is dead, under the slug of Instanodes, which is trading.

* keep the promises instead of withdrawing them

The audit found four things the page claimed and the code did not do. The
last commit made the prose honest by dropping the claims; this one does the
work instead, for three of the four.

Drift detection is armed. 21 artifacts now carry a baseline hash recorded
from their live body, so a provider editing its pricing surfaces as drift
rather than as a stale figure that still reads as current. The remaining
artifacts stay on manual re-reading on purpose: Chainstack's pricing JSON
regenerates every fifteen minutes and the big marketing pages churn on any
unrelated edit, so hashing them would alarm every cycle until everyone
learned to ignore the alarm. Age is published either way, so a figure
nobody has re-read is visible as one.

An artifact we deliberately never fetch no longer reports ok=0. That gauge
read as "the provider's page is down" for 38 artifacts nobody had asked
about, which is a different and untrue claim; it is now simply absent.

The free-tier allowance, the effective cost per million, the billing units
per request and the dedicated cohort's break-even volume were all being
emitted and rendered nowhere. Each now has a panel. The ledger's p50/p90/p99
columns are relabelled: the bill is a constant read from a committed
catalogue, so three percentiles of it are the same number three times, and
the success-rate columns are off because this bench sends no RPC traffic
and had no business showing 100%.

Still not done: the reason a provider is absent from a cell reaches the
harness log and not the page. The prose says so rather than implying
otherwise.

* a volume tab showed another volume's numbers, and a free provider was pruned as missing

Two bugs in shared code, both found by auditing what the page rendered
rather than what the spec said.

The variant fetch never sent the bucket filter, and the all-selection test
that short-circuits it never considered the bucket either. So picking only
a volume fetched nothing, any other fetch came back with the default
volume's figures, and the result was cached under a key that DOES include
the volume. Every bucket tab rendered the default one. This also broke
bench 268's trade-size tabs, which is the dimension the key was added for.

The ledger and the summary strip prune all-zero rows because on a latency
bench a provider emitting 0 ms is dead, not instant. On a cost bench $0 is
the answer: dRPC and OnFinality genuinely serve a ten-million-request month
for nothing, and pruning them made the page name a paid provider as the
cheapest option. Rather than change that rule for 240 benches, a spec can
now declare `zero_is_a_value`, defaulting to false so nothing else moves.

* a dedicated node priced every chain tab, including the ones it does not serve

The audit caught two blockers, both introduced this afternoon while fixing
something else.

Making the dedicated cohort unmetered so it would stop rendering nine empty
rows also skipped unitsPerRequest, which held the only chain check. So AWS,
whose three plans are Ethereum nodes, led the Solana tab at $97.82; Allnodes
showed $320 for its Ethereum plan where its Solana plan is about $1,920; and
Helius's Solana node carried an Ethereum bill. Every plan now declares the
chains it serves, taken from its own published name rather than inferred
from its id, and the catalogue refuses to load dedicated capacity that
declares none. That hole cannot reopen quietly.

The free-tier panel ranked dRPC first on the trace tab at 10.5M requests
while its own free plan is archive: false, trace: false and the ledger
directly below refused it. The panel now applies the same eligibility gates
as the table, so an allowance a plan cannot spend on a workload is not
published as one.

Fixing the first surfaced a third: the break-even denominator was the
cheapest simple-read rate, and simple-read is an Ethereum profile, so every
Solana node was dropped for want of a denominator. The floor is now computed
per chain from that chain's own baseline workload, and a break-even is
labelled with the chain it was actually computed against.

* the sentences describing the numbers went stale every time the numbers moved

Four published claims were hand-written about values the model computes, so
correcting Chainstack's archive rule, the throughput meter and the dedicated
cohort's chain gate silently falsified them. The audit caught it; the text
had been corrected once already and had rotted again by the time the model
settled.

Recomputed from the harness's own 1,485 series: five providers win a cell
outright rather than six, and two more share a cell on a tie; the trace
profile at a hundred million spans 6.7x from NOWNodes at $567.75, not 7.6x
from Chainstack at $499; Alchemy and QuickNode run 1.7x to 10.6x above the
cell leader, not 2.5x; and Chainstack, which the copy still named as a trace
winner, wins no cell outright.

The durable half of the fix is a per-bench plugin in the audit skill that
recomputes all four from Prometheus and fails when the prose disagrees, plus
a check that understands `dimensions.tier` partitions the cohort so the
generic row count stops reporting 20 of 31 as a fault.

* the quotable sentence crowned a provider on a slice it never named

The SEO audit found the page handing answer engines the exact claim its own
intro says cannot be made. "BlockPI posts the lowest monthly cost at $29.50
across 15 ranked providers" appears on the TL;DR, the infobox, the results
heading, the table header, the StatisticalReport JSON-LD, /api/stat,
/api/citable and llms.txt. It holds for the dapp workload, on Ethereum, at
ten million requests a month, on the cheapest paid plan, and for none of the
other fourteen cells. None of those four conditions appeared anywhere in the
served HTML, and /api/stat returned filters: null.

A bench that declares dimensions answers a different question per tab, so
the headline now names the slice it was measured on. A dimension whose first
value reads "All ..." is a genuine aggregate and is skipped, which leaves
every chain- and region-dimensioned bench untouched; tier is skipped too,
since it partitions the provider list rather than the measurement and the
cohort size is already in the sentence.

Also: the meta description rendered at 65 characters because the spec's was
207, over the cap, and the render kept only its first sentence. Rewritten to
139, inside the window.
…#2774)

The bench publishes 36.7 s p50 for Near Intents against 1.0 to 1.5 s for
LI.FI, Mobula and Relay. Most of that gap is ours, not the bridge's.

Two defects, both in how we poll for settlement.

Near Intents slept BEFORE its first status check. The other three check
first and sleep after, so a settlement that has already landed is caught on
the immediate call and costs only an HTTP round trip, which is why they read
near one second. Near Intents had a floor of one whole interval under every
measurement: instant settlement still recorded 15 s.

And the interval differed, 15 s against 5 s, so past that floor its
measurements quantised three times coarser. A p50 of 36.7 s means "seen on
the second or third poll", not "took 36.7 s"; the true figure is somewhere
between 15 and 36 s and this instrument cannot say where.

All four now share one constant and all four check before sleeping. The
constant is 1 s rather than 5 s: the first check is immediate for everyone,
so the interval only bounds the error on settlements slower than a round
trip, and at roughly 90 executions per bridge per month the extra status
calls are a rounding error against any provider's rate limit.

A test pins both properties, because this is the kind of asymmetry that
reappears the next time one bridge needs special handling.

Worth saying plainly: we published a number that made a named provider look
twenty-five times slower than its competitors, and the measurement was at
fault. The 7-day window will take a week to clear once this is deployed.
Every ledger printed its success column as toFixed(2), so the four bridge
execution providers all read "100.00%": two decimals, implying resolution to a
hundredth of a point, from samples of 68 to 95 executions a week whose finest
possible step is 1/68, about 1.5 points. The column distinguished nothing
while looking authoritative, and nothing on the page let a reader tell 100% of
68 from 100% of 68,000.

Decimals now follow the sample. The useful precision of a rate from n
observations is log10(n) digits less the two the percentage scale already
carries, capped at the two this ledger has always shown:

  68 executions   100.00%  ->  100%
  93 executions   100.00%  ->  100%
  74 executions    98.65%  ->   99%
  2,863 probes     99.44%  ->   99.44%   (unchanged, it earned them)

The 30-second probe benches keep what they had; only the figures that were
never supported lose anything.

The cell also carries what the percentage rests on. Where nothing failed it
names the ceiling the sample can support, because "100%" reads as "cannot
fail" and 68 observations cannot establish anything above 98.5%. Where
something failed it gives the count.

An unknown sample size keeps two decimals: coarsening a figure whose sample we
cannot see would be asserting an imprecision we have not established.

The rule lives in src/lib/sample-precision.ts so it can be tested without
rendering, and because it should apply anywhere else a rate meets a sample.
… trim (#2776)

The SEO checks fail this page at 80 characters of title against the 35 to 65
rule, and 222 of description against 110 to 160. Both get cut in a result
page, so what survives is whatever the engine picks rather than the part that
would earn the click.

  title        80 -> 53
  description  222 -> 129

What had to survive the trim is the differentiator, and it now leads both:
these are real executions settled on chain, not quotes. Every other bridge
comparison quotes quotes. The provider names stay in the description, where
they are the second thing a searcher scans for, and the chain list goes: it
was the first casualty of the old length anyway and the page states it twice
above the fold.

Found by running the SEO checks after the success-precision work, not by
looking for it.
…urement (#2777)

Aurora asked us to cover swap insurance, confidentiality, and whether users
are shielded from questionable liquidity. Those are real properties a reader
would want, and this site has no instrument for any of them. It also has no
way to say so: the registry carries free-text fields with no source and no
date, and a page that printed a vendor's claim unqualified would make us the
thing we were built against. The /about page says it plainly, that marketing
pages quote "real-time" without a number and this one quotes the number.

So a claim carries three things a measurement does not need, all required:
where we read it, when a human last looked, and the provider's own words. The
product page renders them in their own section, after the description and
before the measured leaderboard, under a heading that names the speaker:
"Stated by <provider>, not measured here". The note under it draws the line
the section exists for, that we checked the statement appears at the source on
that date and did not verify that it holds.

A claim never enters a benchmark, never ranks anything, never moves a leader.

Two guards. A claim goes stale at six months and says so on the page rather
than ageing quietly into something that reads current, and stale ones sort
last so the section opens on what we stand behind. And a superlative in a
claim value fails the test: the measured columns are where a provider gets to
be fastest, and laundering a boast through a "claim" would be worse than not
having the mechanism.

The registry test walks every entry, so a malformed claim fails the build
instead of reaching a page unsourced.

Nothing is populated. Each claim needs a human to read the source and date it,
and inventing them to demonstrate the feature would be the exact failure this
is built to prevent.

Also corrected a false comment while in there: seven registry fields said they
were "surfaced on the product detail page when present" and not one of them is
read by any code. They are now marked as what they are.
* the quote ladder priced fees three times and depth not once

Aurora asked whether a bridge shields a user from blank liquidity. No quote
API in this harness exposes a price-impact or a warning field, so there is
nothing to read off a single quote. What all eight do expose is an output
amount, and therefore a cost as a share of the ticket, which the harness
already records with an amount_usd label.

The trouble is the ladder. At $5, $50 and $300 the curve only shows a fixed
fee being amortised: debridge costs 62.9% of a $5 ticket and 1.3% of a $300
one, and every bridge falls the same way. Liquidity does not bind anywhere in
that range, so three rungs measured the same thing three times and the
question had no rung at all.

$25,000 is added, to the quote ladder only. The shape of the answer is the
measurement:

  cost% keeps falling or stays flat  the quote ignores depth and promises a
                                     price the corridor may not fill
  cost% turns upward                 the impact is being priced
  no quote returned                  the provider declines the size, which is
                                     the strongest form of shielding

Three readings off the provider's own number, uniform across all eight,
without parsing eight different error formats.

The meme corridor gets the rung in token units, because TRUMP to BRETT is the
thinnest route here and therefore where the question is sharpest.

Nothing is executed. main.go is untouched; its ladder tops out at $30 of real
money and never sees this constant. A test keeps the two orders of magnitude
apart on purpose.

Two more tests, one of them written because this change introduced the fault
it now catches: the meme route briefly carried four USD labels against three
token amounts, and since the quote loop ranges over the amounts, that corridor
would have dropped out of the depth comparison silently.

* whether the source transaction says where the money is going

Aurora says swaps through Intents are confidential. The bench could neither
support nor contest that, because it had no instrument for it, and a property
nobody measures is where a benchmark quietly stops being one.

This measures the narrowest honest version, and the narrowness is the point. A
lock-and-mint bridge takes the recipient as a parameter of its deposit call,
so anyone reading the source chain learns the other end immediately. An intent
system sends a plain transfer to a one-time deposit address and settles
separately, so the same reader learns nothing from that transaction alone.
That difference is real, it is what "confidential" means operationally at the
source, and it can be read without trusting anyone's description of their own
product.

Nothing is fetched. The harness builds the transaction it is about to
broadcast, so the exact bytes the source chain will record are already in
hand. The probe is a byte search for our own destination address in our own
outgoing payload: one rule for all providers, no per-bridge calldata parsing,
which is the trap that makes this sort of comparison lie.

A negative is NOT "the swap is confidential". A solver may publish the link
elsewhere, amounts may correlate, timing may correlate, a one-time address can
still be clustered. The metric is named for the one thing it reads and the
file says so twice.

Two silences, both deliberate, both tested. An address or payload we cannot
decode publishes nothing, because a zero reads as "this bridge does not
disclose" and that is a claim we would not have earned. And a Solana source
whose destination is the signing wallet publishes nothing either: that key
sits in the serialized message as fee payer whatever the bridge does, so the
reading would be about us. No current route hits it, which is exactly why it
needs the guard rather than a comment.

Wired on the Mobula path only for now, its three broadcast sites. The other
bridges take the same one-line call at their own sites; doing them blind,
without reading each quote shape, is how the Near Intents polling asymmetry
got in.

* Relay encodes its Solana keys as text, so the probe needed a kind not a flag

Extending the disclosure reading past Mobula turned up the difference I was
worried about, in the first bridge I looked at. Relay's Solana path hands over
a JSON instruction list whose pubkeys are base58 STRINGS; Mobula's and LI.FI's
hand over a base64 serialized message whose keys are raw BYTES. Searching one
the way you search the other finds nothing, and finding nothing here publishes
"this bridge does not disclose the destination", which is the most flattering
thing the metric can say.

So the parameter is an explicit payloadKind rather than an isSolana bool. The
axis that matters is the encoding, not the chain, and every call site now
declares which it is handing over. A bool would have let the next person wire
a text payload as serialized and ship a provider an unearned compliment.

Six broadcast sites covered: Mobula's three, Relay's two, LI.FI's Solana path.

The self-route guard is no longer Solana-only. Reading it again, a destination
equal to the signing wallet is unmeasurable on any chain for the same reason,
not just where the payer sits in a serialized message.

Two more tests. One pins Relay's shape both ways, that a pubkey in the
instruction list is seen as text AND that reading it as a serialized message
does not quietly return "undisclosed". The other keeps nil instructions
rendering to nothing rather than to an empty JSON array, which would have been
a measurable-looking payload with nothing in it.
…) (#2780)

Thirdweb is declared on 31 chain RPC benches and ranked on none of them.
It answers 0.3 % to 1.5 % of our probes across 29 chains while returning
25 of 25 calls from a consumer connection: it serves browsers and refuses
our probe hosts.

The exclusion was right. A median drawn from a handful of replies is not
a measurement, and the 50 % ranking floor and the 5 % display floor both
exist for that. What was wrong is that the reader never learned it. Below
5 % a provider left the table, left {{count}}, and left the headline, so
a page naming one endpoint read as a chain that has one endpoint.

That is the flattering half of the fact. Our readers deploy on servers
and would meet the same wall, so the refusal is the most useful thing we
know about that endpoint.

Three changes, all derived from the measurement rather than authored, so
an endpoint that recovers rejoins the table on its own:

- belowDisplayFloor() in provider-filters, the exact complement of
  displayResults over the live set, with a test that the two partition it
  so nothing can fall into neither again.
- A "Declared, but not answering our probes" block under the endpoints
  list, naming each one with its URL and its real success rate, and
  saying that a provider can serve a browser and refuse a datacenter.
  This is separate from the authored excludedProviders block, which is
  for a provider we chose not to probe.
- The one-endpoint headline said "the only free public IoTeX RPC endpoint
  measured" on a chain where three were measured and two answered under
  2 % of the time. It now says "the only one of the N endpoints answering
  our probes", which is what the number behind it actually holds.

The section used to return null below two usable endpoints, so the pages
that needed the explanation most were the ones bailing out before it. It
renders when a silent endpoint exists, and still returns null when there
is nothing to paste at all.
The FAQ endpoint sentence paired an authored list of endpoints with
{{count}}, which is the display cohort behind a 5 % success floor. The
two drifted: twenty-nine specs disagreed with themselves, the worst
reading "2 endpoints sustain continuous keyless probing today" above
three names, one of which answers 1.4 % of the time.

The verb was the other half of it. "Sustain continuous keyless probing"
asserts that every named endpoint answers, which is the claim the
measurement contradicts: Thirdweb is declared on 31 chain RPC benches and
answers 0.3 % to 1.5 % of our probes. An endpoint is probed whether or
not it replies, so the sentence says that instead, and the block shipped
in #2779 names the ones that do not reply, with their success rate.

{{declared_count}} is derived from the spec's own provider list, so
neither number is authored any more. Only the names are, and a test pins
them: no spec may claim continuous probing again, none may pair the
display count with that sentence, and every spec using the sentence must
use the derived placeholder.

The validator keeps an allow-list of placeholders and rejected all 88
files on the first run. That is the check working: the token is
registered now rather than the list being bypassed.

An earlier attempt rewrote the whole sentence, mangled the punctuation on
55 files and covered only 55 of 89 because the tails vary. Reverted. This
one replaces the faulty segment and leaves every tail alone.
An audit of all 161 chain RPC benches found seven with no provider
producing a number. Each declared endpoint was re-tested by hand, with
the right protocol per chain rather than eth_blockNumber everywhere,
which is what separated the dead from the merely non-EVM.

Six are retired:

  quicksilver   0 of 3 endpoints alive, last sample 29 days old
  thundercore   0 of 3 (two answer 503), 24 days
  canto         0 of 1 (connection times out), 24 days
  neon          0 of 3, 8 days
  plume         0 of 1 (drpc answers 400), probe fresh
  zetachain     1 endpoint alive, 0.37 % success, probe fresh

The last two are the pair worth reading twice. Their probes run and
their data is current, and what the data says is that nobody answers.
zetachain's single endpoint is Thirdweb, which serves browsers and
refuses our probe hosts on every chain we measure it (0.3 % to 1.5 %
across 29, see #2779).

The seventh, starknet-rpc, is NOT retired. Its probe has produced
unbroken data for at least seven days, OnFinality clears the 50 % floor
at 50.69 % with a 339 ms p50, and every query the spec declares resolves
against Prometheus right now. The published blob is 585 hours old
regardless, so the bench is a publication bug and not a dead chain.
Removing it would have destroyed working measurement.

Specs deleted, slugs added to REMOVED_BENCH_SLUGS so indexed URLs answer
410 Gone rather than 404, matcher kept in sync (its test caught the
first attempt, which had the wrong indentation and inserted nothing).

The six chains leave the harness too, so three probe regions stop
spending calls on endpoints that time out. Five leave the chain registry
with their bench, which removes an empty /chains page: each linked the
RPC bench and nothing else, and all six were already out of the sitemap.
plume stays in the registry because chain-bridged-tvl and
chain-fees-revenue measure it.

The harness edit took three attempts. A regex over the Go composite
literal left an orphan brace twice; `go build` caught both. The version
here cuts on exact line boundaries and passes build and vet.
…2788)

The noindex on /icon was the cause of Google's globe and is fixed
(#2771). This is the gap that survived it: /favicon.ico, the
conventional location every crawler and tool probes directly, returned
404. The rel="icon" link is what Google documents as sufficient, so this
is unlikely to be what finally flips the listing, but a 404 on that path
is strictly worse than a file, and it is the single most-requested URL on
any site.

A real multi-size ICO: 16, 32, 48 and 64 px, each a PNG payload, on the
same #f8f3eb background as icon.tsx with 10 % padding so the mark is not
flush to the edge at 16 px.

Two things cost an attempt each and are written into the generator so
they cost nobody else one:

Next's ICO decoder rejects non-RGBA payloads. flatten() produces RGB, so
the first build died on "Format error decoding Ico: The PNG is not in
RGBA format!". ensureAlpha() after flatten() is required, and the script
asserts four channels per size rather than trusting it.

The file is binary and nobody would remember how it was made, so
scripts/build-favicon.mjs regenerates it from public/logo.png and
explains both constraints.

What this does NOT change: the existing <link rel="icon" href="/icon">
stays exactly as it is. Google wants a stable favicon URL and that one
has already been reset once this morning when icon.tsx was edited; it is
not being touched again.
…#2789) (#2790)

isExpiredBench exists to keep a page that renders noindex out of the
sitemap. It read `b.status === "live"`, and the only rows that ever reach
it are the sitemap rows the worker publishes: slug, lastRunAt, category,
perChainSlugs. No status. So the comparison was false for every row and
the guard did nothing from the day it was written.

The RPC variant beside it has no status check, which is exactly why chain
pages expired correctly and nothing else did. Its own comment describes
perp-asset-breadth sitting in the sitemap with index, follow and
month-old data "because every expiry check downstream was RPC-shaped":
the fix for that was this function, and it was inert.

Found because it blocked a deploy. bridge-execution-latency and
bridge-realized-cost render noindex, both were listed, and the sitemap
smoke gate failed the production deploy and tried to roll back. Their
lastRunAt is the Unix epoch, which materialize/load.ts writes
deliberately when a bench declares a freshness_timestamp_metric and
Prometheus reports no run at all, "so say so rather than pretending". A
56-year-old page must not be advertised to crawlers.

An absent status now counts as live. A draft bench has no business in the
sitemap either, so the default loses nothing, and the worker publishes
the real status from here on so the row carries the fact instead of
relying on a default.

Seven tests pin it, including the case that was broken: a row with no
status at all.
…#2786)

prom.openchainbench.com answers the internet. It is a bare reverse_proxy
with no auth, and Prometheus runs behind it with --web.enable-admin-api,
so a POST from anywhere could delete series. Measured from a laptop:
/api/v1/status/config returned 200 and the delete_series route answered
405 to a GET, which is Prometheus saying "wrong verb", not "go away".

The destructive routes are now blocked at the edge, which breaks nothing
because the access log shows the only paths in use are /api/v1/query and
/api/v1/query_range. This commit closes the rest: an Authorization header
on the one fetch every Prometheus request passes through.

It has to be a header. Node's fetch refuses a URL that embeds credentials
("Request cannot be constructed from a URL that includes credentials"),
so user:pass@ in PROMETHEUS_URL would not weaken authentication, it would
break every query. Tested before writing this.

Absent PROMETHEUS_BASIC_AUTH the helper adds nothing, and that is the
point: the rollout only works in one order, because Vercel binds env vars
at deployment rather than at request time.

  1. this commit ships, inert
  2. PROMETHEUS_BASIC_AUTH set in production
  3. redeploy, so the value is actually bound
  4. basic_auth turned on in Caddy

Reversed, every bench 401s until step 3 lands. A test pins step 1: an
unset or empty variable must produce no header at all.

Not sent over plain http, so the worker's in-network hop to ocb-prom:9090
never carries a credential it has no use for.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
… curve again (#2781)

* $25,000 does not move USDC, so the depth rung measured the fee curve again

First readings off the deployed probe: every one of the eight bridges kept
falling at $25,000. That is not eight providers ignoring depth, it is USDC on
Base, Arbitrum and Solana being deep enough that $25k does not touch it. The
rung works; it was asking a question these corridors answer trivially.

Half a million is where a stablecoin route starts to answer honestly, and a
provider that declines the size has told us something too.

It costs the providers nothing extra, because it replaces the $50 rung rather
than joining it. The ladder already runs 5,760 quotes per provider per day and
a fifth rung would add 1,440 to each, which is how a free key gets revoked.
It does not need one: below the depth point a bridge's cost is a fixed fee
plus a proportional one, cost% = F/amount + V, so two points determine the
curve and a third on it measures nothing. Fitting F and V on $5 and $300
predicts the live $50 reading to a median of 0.3% across all eight bridges,
worst case 13.7% on squid. That rung was interpolation.

The ladder is now $5 and $300 for the fee curve, $25,000 and $500,000 for
depth: same number of quotes, two depth points instead of one, and a turning
point visible between them.

A test pins the budget at four rungs per route, so the next person who wants a
reading has to decide what to spend rather than quietly adding load to eight
third-party APIs.

* main's harness had lost the freshness source its own specs point at

Deploying main put two benches into noindex within the hour. Their asOf read
1970, and /products/mobula, /relay, /lifi and /near-intents all started
failing the links-to-noindex check because they point at them.

The cause was not the deploy. bridge-execution-latency.yml and
bridge-realized-cost.yml both declare

    freshness_timestamp_metric: bridge_last_execution_timestamp_seconds

and nothing in main's harness has emitted that for weeks. Prometheus held 2,835
samples of it over the last day, scraped from the container, because production
was still running a binary built before release 1bff8c7, which took "main's
versions" of the harness while benches 261 and 263 were gated to dev. The
benches later came off the gate; the harness never caught up. Main and
production had diverged, and the only thing holding the freshness source up was
an image nobody had rebuilt.

Restored from 73265b0: last_execution.go, the gauge, and the three attachment
points. The file's own header describes the failure exactly, that a pulse gauge
cannot be a freshness source because it is gone between runs, and the execution
cycle is daily, so without the persisted copy any restart blanks it for up to
twenty-four hours.

The expose-at-start call matters more than it looks. Without it the store is
written and never read back: the file grows, the gauge stays empty, and the
mechanism appears present while doing nothing.

Two tests. One restarts the store in a temp dir and asserts the gauge comes
back carrying the persisted time. The other asserts an empty store publishes
no series at all, because a zero unix time reads as 1970 and that is precisely
how the site concluded both benches were infinitely stale.
* OKX's DEX feed is open after all, on a channel their docs do not mention (#2791)

Their documented trades channel, wss://wsdex.okx.com/ws/v6/dex, answers
"Please log in" to every subscribe including the docs' own verbatim example,
and their fee table says why: Free reads "WebSocket not supported", cheapest
WS-capable plan $99/month.

Their web frontend holds a different socket that needs no credential. Two
things hid it, both of which fail silently:

  - the channel is dex-market-trade-history, not any `trades` variant. Twenty
    other names were rejected before this one;
  - the params go in an extraParams JSON STRING. With flat keys the gateway
    still answers {"event":"subscribe"} and then pushes nothing, which is
    indistinguishable from a dead channel. An ack proves nothing here, only
    frames do.

Base and Solana only, and both exclusions are structural. BNB publishes
head_lag_seconds as receiveTime minus the provider's own timestamp, and every
OKX timestamp is a whole second (1,800 sampled across three chains, all
ts%1000 == 0), so measuring it there would publish our arithmetic as OKX's
latency. Robinhood Chain is not in OKX's supported set.

OKX cannot scope a subscription to a pool: six ways of passing the pool
address are accepted and silently ignored, and the paid channel is
token-scoped too, so this is the shape of the product rather than a level of
access. It does not change what is scored. Only emissions matched to the
reference by transaction hash are, and the reference is the bench pool, so the
scored set is identical to the other four providers'. Verified before shipping
rather than assumed: over 20 minutes on the Base pool, 213 of 213 pool swaps
appeared in OKX's stream, p50 636 ms behind our node, OKX never first.

The one cost is bookkeeping. An off-pool emission never matches and would
expire as a miss, which would read as a 96%-miss provider and drown the signal
that counter exists for: whether the reference itself is healthy.
tokenScopedAggregators exempts it, and a test keeps the exemption to providers
that genuinely cannot scope to a pool.

The feed is undocumented frontend infrastructure with no stability guarantee.
That is the bet geckoterminal_monitor.go already makes here, and the failure
mode is the same: the flow watchdog purges the gauge and the column reads
"Feed down" rather than a wrong number. Tests pin the socket and channel
constants so a silent move fails loudly.

okx.svg replaces the opaque JPEG, which drew a white box around the mark on
dark backgrounds.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* the BNB column was measuring BNB's block time, not the providers (#2792)

head_lag_seconds on bnb published receiveTime minus the provider's OWN
timestamp, because bnb was in neither referenceChains nor raceChains. The
spec justified that by claiming BNB's chain-supplied timestamps sat
"within roughly 60 ms of the moment a trade is observable".

The gap between that series and the reference-matched one IS that
quantity, and it is 569 ms.

So the dominant term in the published column was block time, which every
provider shares, and the column barely separated them. Measured over 24 h,
8,604 samples per provider, eu-west p50:

              published    against the reference
  mobula        0.621 s          0.052 s
  serialized    0.657 s          0.102 s
  codex         0.676 s          0.215 s
  geckoterminal 33.881 s        31.196 s

Within 9% of each other before, spread over a factor of four after, and
the ranking is unchanged. This removes a common offset rather than
reshuffling anyone. p90 moves the same way (1.0-1.2 s to 0.12-0.33 s).

The gate in pending_match.go is that the reference must arrive before
every provider. Our node is beaten 6.6% of the time by mobula on bnb,
4.7% by serialized, 3.7% by codex. Base, already on the reference and
shipping, sits at 4.2% / 3.1% / 0%. Same order of magnitude, so this
clears the bar the project already applies to itself.

The reference subscription this reads already existed and already
populated head_lag_ref_seconds; nothing new is collected. Only which
series the headline reads changes.

Published numbers on the bnb column will drop by roughly 5-10x the day
this deploys. That is a methodology correction, not providers getting
faster, and the spec now says so where it used to carry the 60 ms claim.

TestReferenceChainsScope asserted bnb must NOT be here. Updated
deliberately rather than deleted, with the reason, and a new test pins
why bnb belongs now and why robinhood and solana still do not.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deployed to one region and watched it: the socket connected, both chains
acked, and 0 series appeared. The log line I had written for exactly this
case, "subscribe acked, waiting for frames", never progressed to
"pushing trades".

Two decoding bugs, both silent because the read loop skips a frame it
cannot parse.

First, `data` is a single OBJECT on this channel, not the array every
other OKX channel uses, so `[]okxTrade` rejected every frame.

Second, and the one that survived the first fix: `timestamp` arrives as a
JSON NUMBER on the WebSocket while the REST endpoint of the same product
sends it quoted. A `string` field failed the whole object on that one
field, with the hash already parsed correctly immediately above it.
Measured against the live gateway: 136 of 136 frames rejected.

Neither surfaced earlier because the Node probe that validated the feed
wrote `Array.isArray(m.data) ? m.data : [m.data]` and read the timestamp
through JavaScript's loose typing, so it absorbed both differences
without a word. A probe tolerant where the real client is strict proves
nothing about the real client.

okxFrameTrades accepts either shape; okxMillis accepts the timestamp
quoted or bare and never fails the decode, because losing a whole trade
over a field neither measured chain publishes would cost more than
losing the field.

Verified with the monitor's own decoder against the gateway rather than
a copy of it: 180 trades decoded, 180 carrying a hash. That check ships
as TestOKXLiveFeed, opt-in so CI never depends on a third party, and it
fails with "an ack is not evidence, only decoded trades are".

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
… never name (#2797) (#2798)

I said twice that Birdeye could not be reverse engineered. Both times I was
wrong, and for a reason worth recording: my probes were testing something
other than what I thought.

Node's built-in WebSocket does not send custom headers. Every "Birdeye
refuses us" result came from a client that never sent the Origin and
User-Agent I believed I was sending.

Rewritten in Go and swept one factor at a time against the live endpoint:

  no headers            403 "Just a moment..."
  Origin only           403 "Just a moment..."
  Origin + User-Agent   101 Switching Protocols

So the gate is Cloudflare's default rule rejecting a client that declares
no User-Agent. No TLS fingerprinting is involved and the uTLS machinery in
utls_codex.go is not needed, which also retires the question of whether
defeating a bot protection was acceptable here: declaring a User-Agent is
configuring an HTTP client, not forcing a lock.

Two more things that each produced a wrong conclusion first:

The socket is multichain-socket.birdeye.so, not the public-api.birdeye.so
their documentation names. Probing the documented host is what made this
look closed; that one needs a $199/month Premium plan.

Frames are zlib-compressed JSON. DevTools labels them "Binary message",
which reads like protobuf until you notice the 78 9c magic.

Base and Solana only, for the same two structural reasons as OKX:
blockUnixTime is in whole seconds, so a chain whose headline reads the
provider's own timestamp would publish that quantisation as Birdeye's
latency; and Robinhood Chain is not covered upstream.

Token-scoped like OKX, so most of what arrives is off-bench and discarded.
Only emissions matched to the reference by transaction hash are scored, and
birdeye joins tokenScopedAggregators so its unmatched emissions are not
counted as misses.

Verified with the monitor's OWN decoder against the live endpoint rather
than a copy of it: 1,170 trades decoded, 1,170 carrying a hash. That check
ships as TestBirdeyeLiveFeed, opt-in so CI never depends on a third party.
The OKX integration shipped broken twice because a hand-written probe with
its own copy of the struct said the feed was fine.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
)

* the Birdeye monitor retried a refusal forever, from three regions

Deployed to one region and watched it. Every dial is answered 403 from
Railway, and the monitor did what I wrote it to do: back off, retry, back
off, retry, for as long as it runs. Three regions and two chains each,
against an endpoint refusing all six, indefinitely. That is hammering a
third party for no measurement, and it ran in production before I caught
it.

The 403 is not transient and not a header problem, which is what my own
error message claimed. The same dial, same headers, same binary:

  laptop, residential     101
  OVH VPS, datacenter     101
  Railway EU West         403

So the edge is scoring the egress IP. Retrying cannot change that, and a
monitor that cannot tell "come back later" from "not from here" will keep
asking forever.

A 403 now returns a sentinel, and three consecutive ones disable that
chain with a message saying what is actually wrong. Ordinary disconnects
still reconnect as before; a test pins that distinction, because a breaker
that also swallowed a dropped socket would silently stop measuring a
provider that was merely flaky.

This does not make Birdeye work from Railway. It makes its failure quiet
and honest instead of loud and useless. Whether to route Railway through
a proxy, run this chain from the VPS, or drop Birdeye is a separate
decision with its own costs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Birdeye was the only monitor dialing direct

HTTP_PROXY and HTTPS_PROXY are set on all three Railway services, and
getProxyDialer() reads them. geckoterminal_monitor.go and both mobula
paths already dial that way; I hardcoded a bare websocket.Dialer, so
Birdeye went direct from an egress IP Cloudflare refuses.

That is the whole reason it returned 403 from Railway while the same
binary got 101 from a laptop and from the OVH VPS. I had reported that
no proxy was configured, which was wrong: my grep was anchored to the
start of the line and missed both variables.

The breaker in the previous commit stays. If the proxy is also refused,
three 403s still disable the chain rather than retrying forever.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ilence (#2800)

* the Birdeye monitor retried a refusal forever, from three regions

Deployed to one region and watched it. Every dial is answered 403 from
Railway, and the monitor did what I wrote it to do: back off, retry, back
off, retry, for as long as it runs. Three regions and two chains each,
against an endpoint refusing all six, indefinitely. That is hammering a
third party for no measurement, and it ran in production before I caught
it.

The 403 is not transient and not a header problem, which is what my own
error message claimed. The same dial, same headers, same binary:

  laptop, residential     101
  OVH VPS, datacenter     101
  Railway EU West         403

So the edge is scoring the egress IP. Retrying cannot change that, and a
monitor that cannot tell "come back later" from "not from here" will keep
asking forever.

A 403 now returns a sentinel, and three consecutive ones disable that
chain with a message saying what is actually wrong. Ordinary disconnects
still reconnect as before; a test pins that distinction, because a breaker
that also swallowed a dropped socket would silently stop measuring a
provider that was merely flaky.

This does not make Birdeye work from Railway. It makes its failure quiet
and honest instead of loud and useless. Whether to route Railway through
a proxy, run this chain from the VPS, or drop Birdeye is a separate
decision with its own costs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Birdeye was the only monitor dialing direct

HTTP_PROXY and HTTPS_PROXY are set on all three Railway services, and
getProxyDialer() reads them. geckoterminal_monitor.go and both mobula
paths already dial that way; I hardcoded a bare websocket.Dialer, so
Birdeye went direct from an egress IP Cloudflare refuses.

That is the whole reason it returned 403 from Railway while the same
binary got 101 from a laptop and from the OVH VPS. I had reported that
no proxy was configured, which was wrong: my grep was anchored to the
start of the line and missed both variables.

The breaker in the previous commit stays. If the proxy is also refused,
three 403s still disable the chain rather than retrying forever.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* say whether the proxy is actually there, instead of falling back in silence

Birdeye's edge refuses this project's Railway egress, so the dial only
works through the proxy. getProxyDialer falls back to a direct dial when
HTTP_PROXY is missing, silently, and that fallback makes two different
failures look identical: no proxy configured, and a proxy that is itself
refused.

It cost an afternoon. The same dialer shape, with the same proxy URL read
out of Railway, connects from a laptop:

  Proxy only                      101
  Proxy + custom NetDialContext   101
  getProxyDialer shape            101

while the deployed service answers 403 on every attempt. So the dialer is
not the problem and the proxy is not refused; the remaining candidate is
that the variable is not in the container's environment at all, which is
precisely what nothing in the logs could tell me.

Startup now prints which of the two it is. No value is logged, only its
length.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…#2801)

Two assumptions I never checked, and one of them is probably the answer.

getProxyDialer sets Proxy only when url.Parse succeeds and drops it in
SILENCE otherwise, so a malformed value dials direct and looks exactly
like a refused proxy. The startup line added earlier proves the VARIABLE
exists; it proves nothing about whether this dialer carries it. Two
different facts, and I treated them as one.

And a proxy refusing an unauthorised source IP and an edge refusing a
datacenter both surface through gorilla as the same bad handshake 403.

Everything else is eliminated by measurement: the same proxy URL read out
of Railway connects from a laptop; three dialer shapes including
getProxyDialer's exact one all connect; getProxyDialer() is called; the
container reports the variable at the same 52 chars that work here.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Flotapponnier and others added 28 commits October 8, 2026 20:57
#2896 made the keyed probe measure both arms deliberately: rpc_latency_milliseconds
is now explicitly the WARM one (pooled connection) and rpc_latency_cold_milliseconds
is the same call on a connection that is never reused. Both are flowing from all
three regions. Without these rules the cold series exists but no page can read it,
because every spec queries the precomputed ocb: shapes rather than raw gauges.

Four rules mirroring the warm ones, same group, same 60 s interval, and no tier
filter — the keyed harness emits the same metric names with tier="keyed", so one
rule set covers both cohorts and all three regions, exactly as the comment in
prometheus.yml says of the existing ones.

## Context for whoever reads this later

Every probe used to share one implicit http.DefaultTransport. Whether a call rode
a pooled connection or paid a fresh TLS handshake depended on how often its
HOSTNAME happened to be touched, which is not evenly distributed: a provider
serving nine chains from one hostname stayed warm, a provider using a hostname per
chain went cold between 60 s ticks and paid about 25 ms. That gap, not node speed,
was most of the published keyed ranking. Three of four providers were reported 4
to 6x slower than they answer.

Series before 2026-10-08 are a mixture of both arms and are not comparable with
either one after it.

## Still to come

The nine keyed specs do not read the cold series yet. The plan is a ranked WARM
column plus a non-ranked cold-start figure, rather than two competing rankings:
the cold number mixes provider quality with the distance from our probe regions to
their TLS termination, so it is partly a fact about where Railway put our
containers. Warm isolates what the provider controls and is the number a reader
elsewhere can transpose.

Validated with the repo's own js-yaml: group ocb_rpc_capabilities, 11 rules.
…2900) (#2901)

Third correction in the same disclosure block, and the third time it came
from the provider rather than from us. GetBlock says the endpoints they gave
us sit on their Enterprise plan; nine specs said "a Starter plan, 50M CU per
month, 20 CU per call".

The quota goes with it rather than being restated under the new plan name.
Those figures describe Starter, nobody told us what the Enterprise allowance
is, and inventing one would repeat the mistake being corrected. What survives
is the part that matters for reading the numbers: the probe regions share a
single account, so they share whatever the limit is.

GetBlock joins the provision sentence. Three of the four keyed providers
handed us access; only Alchemy is a signup any reader can repeat, which is
the distinction that block exists to draw.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The nine keyed benches now carry a third ACCESS value, "Private, cold start",
alongside "Public, no key" and "Private, API key". Same endpoints, same call,
measured on a connection that is never reused: DNS, TCP and the TLS handshake
are paid every time. That is what a serverless function or a one-shot client
experiences; the API key cohort is the pooled equivalent a long-running backend
gets.

36 provider clones, four per spec, pointed at the ocb:rpc_latency_cold_* rules
added in #2899. Verified against Prometheus before shipping: the generated p50
query for getblock on Ethereum returns 23.8 ms, so the column renders rather
than blanking.

## Why a third button and not a nested selector

The ask was a selector that appears inside the Private tab. The tier dimension
partitions PROVIDERS, not PromQL labels, and the dimension machinery can inject
a label but cannot swap a metric NAME. Warm and cold are the same providers
against two different metrics, so a nested control needs new code in
benchmark-body.tsx plus a new dimension kind. A third tier value needs none.

It is also arguably the clearer surface: one row, three mutually exclusive
cohorts, no hidden state. The nested version stays possible later if the three
buttons read badly.

Provider slugs are suffixed `-cold` because the panel label IS the provider
slug and a duplicate would collide — the bug that blanked five ledger columns
on bench 284. The `provider=` label inside each query still points at the real
provider, so the series resolve.

## Caveats a reader should get, and the specs do not say yet

The cold figure mixes provider quality with the distance from our probe regions
to their TLS termination, so it is partly a fact about where Railway put our
containers. Warm isolates what the provider controls. If only one number is
quoted, it should be the warm one.

Both arms have about an hour of history: the keyed warm series was reset today
after a measurement bug, and the cold series did not exist before this morning.
Percentiles will be noisy until tomorrow.

pnpm validate: 245 specs.
The keyed-cold cohort shipped with slugs suffixed `-cold` so they stay distinct
from the pooled rows (a duplicate slug collides: the panel label IS the provider
slug). But logoPath is RAW[ALIASES[key] ?? key] and nothing strips a suffix, so
getblock-cold and friends resolved to null and the ledger drew GE / QU / AL / CH.

Four aliases, each pointing at the real provider's existing mark.

This is the same mistake as the "-official" slugs earlier today, and the check
that catches it already exists: preflight.py --slugs <chain> resolves every
provider through the manifest's own rule and stats the file. I wrote it this
morning and did not run it before shipping the cohort. It now passes.
…#2905)

#2896 added warm and cold arms to stop the ranking depending on accidental
connection reuse. It made that dependency worse.

Alternating the two arms doubled each one's effective interval, from 60 s to
120 s, and most endpoints drop an idle connection inside that window. The
connection trace on eu says it plainly:

  shared.eu-central-1.getblock.io                reused=TRUE   tls= 0ms
  wispy-white-bush.ethereum-mainnet.quiknode.pro reused=false  tls=10ms
  neat-dawn-shape.bsc.quiknode.pro               reused=false  tls=10ms
  ethereum-mainnet.core.chainstack.com           reused=false  tls= 9ms
  polygon-mainnet.g.alchemy.com                  reused=false  tls=10ms

GetBlock serves all nine chains from one hostname, so the other chains' traffic
keeps its socket hot every ~13 s. Everyone else uses a hostname per chain,
touched once per 120 s, and pays a handshake every time. The "warm" column was
therefore warm for one provider and cold for the other three, which is the
original bug amplified rather than removed. Every keyed number published since
#2896 is wrong, including the p50 table I read off it.

## The fix

primeWarm sends one unmeasured request through warmTransport before the
measured one, so the measurement is guaranteed to ride a pooled socket instead
of hoping one survived. The response is drained before closing: a body left
unread never returns to the pool, which would defeat the whole call.

The quota guard now reserves two requests on a warm tick instead of one, so it
counts what we actually send. That is +50% volume against budgets running
600k-2M per region per month on ~195k used.

Determinism is the point. A warm figure that depends on whether a socket
happened to survive is not a measurement, and it systematically favours whoever
reuses one hostname across many chains.

## Verification

PROBE_TRACE_CONN=1 is already set on rpc-keyed-eu. The check is that every
hostname logs reused=true on warm ticks, not just GetBlock's.

go build and go vet clean.
"Private, cold start" shipped as a third ACCESS button beside "Public, no key"
and "Private, API key". It reads as a third kind of access, which it is not: it
is the same private endpoints, same call, measured on a connection that is
never reused.

So page.tsx filters keyed-cold out of the access row, and BenchmarkBody renders
a Pooled / Cold start toggle in the chart header instead, visible only on the
private cohort and sitting next to the numbers it changes. It switches the tier
directly, so the URL, the variant cache and the ledger all follow with no new
plumbing.

Each button carries the distinction in its title: pooled is what a long-running
backend gets, cold start is what a serverless invocation gets. The gap between
them is a TLS handshake, and until 2026-10-08 this bench measured whichever one
happened to apply and ranked providers on that accident.

The data model keeps keyed-cold as a tier because that is how a cohort is
expressed here; only its placement in the UI changes.

tsc --noEmit clean.
The old SVG was a transparent outline glyph that floated inside the round
chip with the page showing through. The one they supplied is a square: white
glyph on their blue, so it fills the chip edge to edge the way the other
brand marks do.

The old file is removed rather than left beside the new one. Nothing else
referenced it: one entry in the logo manifest, and the chainstack-cold alias
that points at that entry and follows it.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…es removed (#2912) (#2913)

* fold the cold-start slugs into their vendor, and let a URL ship without prose

Two things stood between 192 product pages and an official outbound link.

The cold-start cohort added yesterday gave each of its four `-cold` slugs its
own product page: /products/alchemy-cold and three siblings, indexable, in the
sitemap, duplicates of the real vendor with no description and no link, because
the registry has no entry for a measurement arm. They are the same four vendors
on a connection that is never reused, so they belong on the vendor's page.
PRODUCT_ALIASES folds them, which collapses their bench appearances onto
/products/alchemy and removes the four pages from generateStaticParams and the
sitemap. It also restores parity with the logo-manifest ALIASES entries these
slugs already carry, which the comment on PRODUCT_ALIASES says it mirrors.

The other 188 were blocked by the registry type: `description` was required, so
nobody could publish a provider's verified homepage without also writing a
sober one-liner for it, and the result was pages with no outbound link at all.
A verified URL is worth publishing on its own and the description can follow,
so `description` is now optional.

Every consumer already guarded it except two, both fixed here: the product
page's paragraph, and the parent page's "related products" list, which took the
child's display name from the first sentence of its description and now falls
back to the slug. The link list also stops floating right when there is no
paragraph to sit beside it, since a lone link pushed to the far edge with
nothing on its left reads as a stray element.

No link changes shape: withUtm already tags every rendered outbound href with
utm_source=openchainbench, and the registry keeps clean URLs so the JSON, the
citations and the structured data stay canonical.

tsc --noEmit clean. 22 tests pass across the registry, providers and utm suites.

* official URLs for 157 product pages, and repair 30 rotten ones

/products/chainstack has an official link. Most product pages did not: the
link block needs a registry entry, and a fetch of all 563 published pages
found 192 rendering no outbound link at all. Seven of those were my own
measurement error (a registry key written in quotes was invisible to the
script that read the file, so aster, standx, unigox, conduit, mvx-api,
mvx-gateway and xlayer-official were counted as missing while serving a link
all along). The rest were real.

ADDED: 157 url-only entries. Each URL was researched, then fetched and held to
five conditions before it went in: it answers, its final host is the one we
asked for and not somewhere it bounced to, the page carries no domain-broker
boilerplate, the page names the product, and where several slugs share a host
that sharing is deliberate. Several rows of one brand do share a host on
purpose, which is what market-category rows (kalshi-crypto, polymarket-sports)
and numbered validators (hyper-foundation-1..5) are.

UPDATED: 20 existing URLs that no longer point where they should. These
had rotted silently, because nothing in the build ever fetches them: ParaSwap
is Velora, Avantis is Veranta, Algonode is Nodely, Swirlds Labs is Hashgraph,
and avax.network, debridge.finance, everstake.one, gelato.network, moralis.io,
nspcc.ru, ramp.network, smartpy.io, kamino.finance and ostium.app have all
moved. OpenOcean's apex no longer serves; its app subdomain does. Nozomi's
page moved from /products/ to /services/. And oktc-official pointed at
www.okx.com/oktc, which 404s today along with /okc and /en/oktc, so OKT Chain
falls back to the parent brand.

polymarket.com is worth a note because it nearly got "fixed": it failed with
an expired certificate here, and the certificate turns out to be CN=*.anj.fr,
the French gambling regulator's block page served by DNS interception. The
site is fine; the URL stays.

DROPPED: 10 URLs whose domain is gone. Seven have no A record at all
(ckbapp.dev, echosync.io, iconblockchain.xyz, trysuper.co, trojan.bot,
tzbeta.net, wavesnodes.com); three resolve and serve nothing (flowbot.pro,
superstack.xyz, kinto.xyz). Each was re-checked against 8.8.8.8 before being
called dead, because this machine's resolver blackholes some domains and that
looks exactly like an expired one: apex.exchange read as a refused connection
locally and is in fact alive, so its link stays.

NOT PUBLISHED, on purpose: taiyorobotics.com for graphite-protocol, which
never names Graphite; hypurrscan.io for hypurrscanning, where the operator is
inferred from the validator's name alone; vibeliquid.xyz, which answers 503.
Joint-venture validators written a-x-b-x-c mostly have no site of their own
and keep none. neutron.org is left as it is and flagged instead: every host
under it, apex and www and app and docs, redirects to one tweet, and guessing
a replacement would be worse than reporting it.

Links need no change to carry attribution: withUtm already tags every rendered
outbound href with utm_source=openchainbench, and leaves the stored URL clean
so the JSON, the citations and the structured data stay canonical.

The product page now renders the link only when there is one and the section
only when it holds something, so a dropped URL leaves no empty row.

tsc --noEmit clean. pnpm validate clean. 492 tests pass.
/products/conduit renders no official link in production, and it was the one
remaining product page where that is fixable: the entry already exists on dev
with a verified URL and never crossed over. It is also the only registry key
that still differed between the two branches.

www.conduit.xyz re-checked: 200.
) (#2916)

* the sitemap kept advertising the slugs it had just stopped serving

Folding the four `-cold` slugs into their vendor left ten redirecting URLs
in the sitemap: the four product pages, now 308, and the six /compare pairs
built out of them. The deploy smoke gate checks every sampled sitemap URL for
a 200, found /products/quicknode-cold at 308, and rolled the prod alias back.
It was right to.

getProviders() does fold aliases when it builds from bench rows, but a stale
store keeps rows under the old slug and so keeps the aliased profile alive
until the store refreshes. Two sections then trusted it: the product routes
treat a slug present in the profile list as canonical and skip the redirect
check entirely, and adHocPairs() builds compare pairs straight off the list.

Filtering the aliased profiles out once, where the list is loaded, fixes both
from the alias table instead of from whatever the store still holds. Any
future alias is covered the moment it is added, with no wait on the worker.

tsc --noEmit clean, 492 tests pass.

* drop the Blast link, and call XPLA by its name

Two corrections from the link audit.

Blast: blast.io is Blast's own domain and plainly Blast's page, but the
headline is "Blast is winding down" and the site now only offers bridging
back to Ethereum. Linking it as a live product sends a reader to an exit. The
url-only entry goes; the bench row is a separate question.

XPLA: a governance proposal to rename the token, its symbol and the brand
identity of the chain to CONX passed on 2025-11-10, and xpla.io now redirects
to conx.xyz, which names CONX throughout. So the chain is renamed on the
surfaces a reader sees: the chain label, its native symbol, its website, and
the bench's title, subtitle, intro and abstract.

The slug stays `xpla` deliberately, and so does every query. It is the
Prometheus label the bench is pinned to (chain="xpla"), it is the page URL,
and the endpoints are still named for it: dimension-rpc.xpla.dev,
xpla-rpc.publicnode.com, xpla-rpc.polkachu.com. Renaming the label would blank
the published columns and reset the page's search history to buy nothing. The
title keeps XPLA alongside CONX for the same reason, since that is what a
reader still searches for.

pnpm validate clean (title 46 chars, description 146). 492 tests pass.
Asked where the Chainstack link was on /products/chainstack, and whether it
had been added at all. It had. It was 11px uppercase in muted grey, right
aligned at the far edge of a paragraph halfway down the page, and the honest
reading of that question is that the placement failed: 157 links shipped
yesterday into a spot where a reader looking for them does not find them.

Both headers now carry it directly under the title, at full text size in the
foreground colour, with the accent on hover: the product page shows the domain
and the X handle, the chain page its website. The product page keeps the
description where it was and no longer repeats the links beside it, so there
is still one link per destination.

No change to what is linked or how: withUtm still tags every outbound href and
the stored URL stays clean.

tsc --noEmit clean, pnpm validate clean, 492 tests pass.
)

* the bench said seven relays and one of them no longer exists (#2919)

bloXroute's max-profit relay has no DNS record at all: NXDOMAIN at 8.8.8.8,
so not a local resolver artefact. It delivered 0 payloads in the last 24h
while the other six all moved (ultrasound 5841, titan 5523, aestus 2912,
bloxroute-regulated 2813, flashbots 264, agnostic 131).

The row looked alive for a precise reason worth recording: ebb_relay_payloads_total
is a counter, and a counter never goes down, so it kept displaying 26,395
accumulated payloads long after the host stopped resolving. Reading the total
says nothing; only increase() over a window does.

The spec advertised "seven MEV-Boost relays" in four places, including the FAQ
answer, and named bloXroute max-profit in two of them. Corrected to six.

Safe to drop: no published panel or query pins a relay label, the counter is a
methodology cross-check only, so no column goes null.

go build and go vet clean, pnpm validate clean.

* bench 025 has been measuring three oracles while saying four (#2920)

Pyth Hermes' price paths now answer 401 to an anonymous client, and the
harness hardcodes one of them. Its Pyth leg has fetched nothing for at least
30 days: ocb_oracle_price{source="pyth"} shows 0 value changes over the full
retention window, against 746,773 for coinbase.

The published numbers were never wrong, which is exactly why nobody noticed.
The headline reads the time-aligned family, and lookupNearest cannot find a
30-day-old point inside alignTolerance, so Pyth was dropped and the figures
stayed honest 3-source deviations (6 to 8.5 bps, entirely plausible).

What was wrong is everything the page said. The subtitle, the SEO description,
the abstract, the Sources bullet and two FAQ answers all named Pyth as one of
four live sources, and the Sources bullet called all four "free, no-auth,
public endpoints". Corrected to three, with the reason stated.

Detection failed three ways and all three are worth remembering: the price
gauge holds its last value forever, ocb_oracle_alignment_miss_total reads 0
for every Pyth pair because a miss is only counted once a lookup is attempted,
and the one counter that did move, ocb_oracle_scrape_errors_total at ~34,500 a
day, has no reader. Same shape as the dead MEV relay found an hour earlier: a
counter that never goes down makes a dead source look alive.

The poller is commented out rather than deleted, so a key restores it in one
line. It was also sending ~34,500 rejected requests a day to a third party.

title and seo_title are deliberately untouched: they carry the page's ranking
history on "Chainlink vs Pyth", and retitling, or paying for a key to restore
the fourth source, is a call with a cost attached.

go build and go vet clean, pnpm validate clean.
…rm bar (#2923) (#2924)

stale_but_moved claimed to fire when a feed is "late by its own standard".
It did not. Both thresholds were uniform constants, and the comment
justifying one of them was false: 0.5% was described as matching "the widest
deviation trigger configured on the measured Chainlink feeds" when per
Chainlink's own published directory the widest is 2%, four times that.

Measured, not theoretical: AVAX/USD at 7.6h and BNB/USD at 8.3h both read
stale_but_moved=1 at the time of writing while behaving exactly as
configured, because a 2%/24h feed was being graded against a 0.5%/300s bar.
SOL/USD was flagged the same way at 457s.

The real configuration, from
reference-data-directory.vercel.app/feeds-mainnet.json (fields threshold and
heartbeat), checked 2026-10-09 and hardcoded in config.go so every value is
auditable in the diff:

  BTC, ETH, LINK      0.5%   3600s
  BNB, MATIC          1%    86400s
  SOL, AVAX           2%    86400s

Each feed is now judged against its own promise. stale_but_moved fires when
the CEX reference crosses THAT feed's trigger and it still has not written
after a 300s grace, which is the time an oracle legitimately needs to observe,
aggregate and land a transaction. The new ocb_oracle_heartbeat_breach covers
the other half of the promise, and is published only for feeds that state a
heartbeat so an absent series cannot be read as "never breached".

Also pauses the RedStone poller, dead 64 days and invisible because bench
082's page is retired. Not a paywall and not a URL fix: its per-symbol API now
returns an empty array, and every documented replacement was probed. The only
live source is an unfiltered 1.98 MB payload taking 12.7 s, so ~237 MB/hour at
this cadence to learn an age that reads 15-25 s whenever you look.

go build and go vet clean, pnpm validate clean.
…oints (#2929)

* Ankr does serve Robinhood Chain, it just will not serve us (#2925)

The page said "does not list Robinhood Chain". It does: rpc.ankr.com/robinhood_mainnet
answers, and the refusal is -32052 "API key is not allowed to access blockchain",
which is a plan error. A chain Ankr did not route would not answer at all.

The distinction matters to a reader choosing a provider: "this chain is not
supported" and "this chain needs the Premium plan" lead to different decisions.

The other seven pages that exclude Ankr say "requires an API key" or "403
without an API key" and are all accurate; only Robinhood was wrong.

pnpm validate clean.

* three answer pages recommend an endpoint that was switched off (#2926)

Every Lava keyless host now answers {"error":"This endpoint has been
discontinued."} - verified on solana.lava.build, eth1.lava.build and
base.lava.build.

Three answer pages still presented Lava as one of the endpoints that "sustain
continuous no-key probing" and are "ranked live above", including the Solana
page calling it one of "the 5 endpoints that actually answer keyless in 2026".
These pages exist to be cited by search and by models, so a dead endpoint
listed as live is the worst place for the error to sit.

Counts corrected with the lists: seven to six on Ethereum and Arbitrum, five to
four on Solana. Lava is now named as discontinued rather than deleted, because
a reader arriving from an older roundup needs to be told why it is gone.

The bench tables themselves were already honest, rendering Lava with a 0
success rate and an unresponsive flag; only the prose was wrong.

pnpm validate clean.

* Dwellir joins the private cohort on the eight EVM chains (#2928)

* Dwellir joins the private cohort on the eight EVM chains

Dwellir was already half-wired: a canonical name, a registry entry and a logo
were committed, and only the key was missing. The key arrived, so this adds
the provider everywhere it can actually be measured.

Verified against the live endpoints before writing a line of spec: all eight
EVM chains answer eth_getBlockByNumber with the key, including the three where
our private cohort was thinnest. Hyperliquid, Robinhood Chain and Arc get
their first new keyed provider since the cohort was created.

Solana is deliberately absent. Dwellir publishes no Solana endpoint, their
networks index does not list it, and the harness skips a (provider, chain)
cell whose URL variable is unset, so that page keeps four providers with no
special casing.

Budget: the free tier is 100k responses a day, 1 response = 1 credit, so ~3M a
month across the three regions sharing the key. Eight chains cost 173k per
region per month at the 120 s cadence. The 1M per-region default is a runaway
guard, not a quota.

The logo was replaced, and that is worth explaining. The committed
dwellir.svg is the vendor's wordmark on an A4 artboard whose artwork runs past
the viewBox: it renders as a cropped "DWELL". Rendering it is how I found out.
Their only square mark is the favicon, so that is what ships, at 128px, which
is what getblock and chainstack already do.

preflight PASS on all nine pages including the 8 new dwellir-cold slugs, after
it caught the alias being written into the path map instead of ALIASES: the
same blank-chip bug this guard was written for.

pnpm validate clean, tsc clean, 495 tests pass, go build and go vet clean.

* Dwellir is on a paid Developer plan, not the free tier

I wrote "Free plan key, 100k responses per day" into all eight specs from the
public pricing page. The account is on the Developer plan: $49 a month, 25
million API responses, 100 responses per second, 1 RPC response = 1 API credit
with no compute units.

This is not cosmetic. Every keyed page promises a disclosed plan tier for every
provider in the cohort, so a wrong tier is a published factual error of the
same kind as the seven relays and the four oracle sources.

Two places needed it. The row tag, and the "Plan tiers, disclosed" methodology
bullet, which named four providers while the table is about to show five. That
bullet already runs 482 to 484 characters against a 500 cap, so Dwellir gets
its own bullet rather than a rewrite of other vendors' disclosures.

The budget comment said free tier too. Three regions share the key, so the plan
allows 8.3M per region; the eight chains cost 173k per region per month, under
1% of it. The 1M guard stays, correctly described now as a runaway guard rather
than a quota.

pnpm validate clean, go build and go vet clean, 495 tests pass.
…ied (#2932) (#2933)

The page carries a backlink and showed a single row, which is why it went
noindex as thin content. The harness was configured with three providers all
along and its comment still said "3 keyless providers"; two of them had
stopped answering and only drpc ever reached the page.

Neither was a dead project. rpc.etcdesktop.com serves a certificate that does
not cover that hostname, while etc.etcdesktop.com answers normally, so that
was a hostname error on our side. etcmc.rpc.nz has no DNS record anywhere and
is replaced by ETC Network.

Both replacements were probed with the call the bench actually makes,
eth_getBlockByNumber, not a liveness ping, and all three returned the same
head (0x1852588) before inclusion. That distinction mattered: 61.rpc.thirdweb.com
answers eth_chainId with 0x3d and then fails eth_getBlockByNumber with an
internal error, so a chainId check alone would have admitted a provider that
cannot serve the benchmark.

Three providers clears the under-3 gate, so the page becomes indexable again.

etcdesktop ships without a logo and renders initials. Their site's only logo
asset is assets/img/logo/home2.png, a website template's placeholder, and
publishing that as their brand would be worse than initials. ETC Network
publishes a real mark and it is committed.

go build and go vet clean, pnpm validate clean, tsc clean, 515 tests pass.
#2936)

nine production pages quote an Alchemy limit Alchemy retired in August

Every keyed RPC page discloses the tier each probe runs on, and the Alchemy
line reads "a Free plan key (30M CU per month, 25 requests per second)". The
30M is right. The 25 rps is a figure Alchemy stopped publishing six weeks ago.

Our own pricing catalogue says so by name, with the commit that did it:
"Throughput RESOLVED to 300 CU/s by wave 2: alchemyplatform/docs commit
3e0a4485, 2026-08-24, 'Update Free tier throughput limit from 500 to 300
CU/s'. www.alchemy.com/pricing still renders the stale '500 CU/s (25 rps)'
and must not be published."

25 rps is that retired 500 CU/s divided by a 20-CU request. Read live today,
alchemy.com/docs/reference/pricing-plans.md gives
"| Base Throughput (CUs/second) | 300 |" for Free, and the marketing page has
caught up as well: it now renders "300 CU/s (15)".

Replaced with what Alchemy publishes, in Alchemy's own unit. Not a converted
request rate: the conversion depends on the method mix, which is the whole
subject of bench 282 next door, where the same 300 CU/s buys 11.5 requests a
second of the dapp mix and 30 of plain eth_blockNumber.

Cherry-picked from dev (#2930), which carries this with the rest of an audit
of benches 282 and 283 against the providers' own documentation. Only the nine
disclosure lines come to main: 9 files, 9 insertions, 9 deletions, nothing
else. The benches that audit corrects are both dev-gated.

I wrote these lines on 2026-10-09 while correcting a different inaccuracy in
the same sentence.
/api/freshness ran one instant query per live spec on every 30 s cache
fill: 509k Prom queries a day, 30% of all outbound calls the project
made, each billed as an Observability Event.

Prometheus.dataAgesSec() folds them into one union query. timestamp()
must wrap the bare selector, otherwise it returns evaluation time and
every recording rule reads 0 s old, so each metric gets its own term.
Checked live: 246/246 specs agree with the per-spec probe within one
scrape interval, 77 metrics in ~240 ms.

(cherry picked from commit e8a3260)
…2946)

The VPS worker writes cohort snapshots (rpc-hub, perps, pm-hub,
search-featured...) to the VPS Redis behind SRH. Since the 2026-09-28
team migration the site's KV_REST_API_* point at Upstash, which only
gets staging's occasional writebacks: rpc-hub-v3 was 70 min old there
against a 10 min read ceiling, 5 min old in SRH. Every miss rebuilt the
RPC hub from ~100 variant blobs, on every /products render.

cohort-snapshot now prefers a COHORT_KV_REST_API_URL/TOKEN pair
(https://kv.openchainbench.com, the SRH route Caddy already exposes).
KV_REST_API_* stay on Upstash because snapshot.ts and compare-cache use
path-style /get and /set routes SRH does not serve. Credentials are now
picked as pairs so a URL never travels with another store's token.

(cherry picked from commit 139a168)
…lines in a 400 px card (#2955) (#2957)

terminal-fill-quality and terminal-execution-quality were 2.4 MB of HTML
each, 85% of it the audit table's full sample serialised as a client
prop. The page now embeds the 50 most recent rows and the table fetches
the rest from /api/terminal-fills/swaps (CDN-cached 5 min) after
hydration; the summary strip and the CSV wait for the full set.

/benchmarks was 2.5 MB, 1 MB of it sparkline SVG. Bench cards now draw
their 12 best lines (the legend still counts every provider), the card
projection carries only those series, coordinates take one decimal, and
the shared stroke style sits once on a group. Sparkline markup -38%.

ISR writes bill per 8 KB written, so page weight times regenerations is
what that line of the bill measures.

(cherry picked from commit f70a199)
Serialized moved its stream to wss://ws.serialized.xyz/v1/stream (same
key, same frames; events gain a `cursor`, subscribed an `updatePeriod`,
both ignored by the decoder) and will retire wss://api.serialized.xyz.
The URL was a constant; it is now SERIALIZED_WS_URL with the new engine
as default, so a rollback is a Railway variable, not a deploy.

Checked side by side with the production key: identical auth, the same
event count and fields on Solana and Base over 15-20 s, and a local run
of the harness published head_lag_ref_seconds on base, bnb and solana.

(cherry picked from commit 2419544)
The L2 goes, so does everything we measured on it:
- blast-rpc retired: spec deleted, slug in REMOVED_BENCH_SLUGS and the
  middleware matcher, so the indexed URL answers 410 Gone.
- its rows on l2-block-time, network-fees and chain-bridged-tvl, with
  the prose, findings and FAQs that named it (l2-block-time is 8 L2s).
- the /chains/blast page (chain registry, the /products/blast redirect,
  kpi label), the live and speedtest chain lists, the registry entry and
  the logo.
- harness entries: l2-block-time, transaction-fee, rpc-capabilities
  (the 4 Blast RPC providers, and the 3 leftover Blast API probes on
  Arbitrum, Base and BNB, which no bench showed and which Alchemy now
  answers), chain-kpis, and the RPC bench generator.

Predict Fun's chain label moves to BNB Chain, its other deployment; the
venue is read by DefiLlama protocol slug, so its data does not change.
Chain-id maps in the bridge harnesses stay: they only translate names.

(cherry picked from commit 347a4a8)
… up (#2973) (#2974)

The Blast removal deploy was rolled back by the sitemap smoke test:
/benchmarks/l2-block-time/blast and /benchmarks/network-fees/blast were
still in the sitemap and 308 to their parent. Per-chain sub-pages come
from the worker's blob, built from the worker's own checkout, which had
not been rebuilt. Benches were already filtered against this build's
specs; the chain sub-pages now are too (per_chain_explainer). Locally the
sitemap drops exactly those two URLs against production's.

Also: l2-block-time measures 9 L2s now, so the title says "and 6 more",
and the answer page is back to 9 (it read 8 after the removal).

(cherry picked from commit 7c3a954)
… (#2976)

It used to 308 to /chains/blast. With the chain gone (#2971) it rendered
an indexable "Blast: chain benchmark" page from the worker's last blob.

(cherry picked from commit 34b2c57)
… lag (#2986)

On 2026-10-14 the us-east probe read Serialized at 60-118 s while
eu-west and sgp read under 1 s and the other feeds in the same process
read 1-3 s. Most spikes fall in windows where every socket on that box
was dropping (reference node, Mobula, OKX, GeckoTerminal). The others
redial within seconds; the Serialized client waited out a 90 s read
deadline, so a frozen path that resumed delivered its backlog at once.

- ping every 10 s and read with a 20 s deadline (two missed pongs);
  verified against ws.serialized.xyz, pongs return in ~100 ms
- reset the reconnect backoff after a session that streamed for over
  2 min; every drop after the fifth was waiting the 60 s ceiling
- log each trade over 3 s with hash, Serialized's `at` and our receive
  time, so the next spike can be checked tx by tx against their logs
- drop and log a trade delivered twice, and do not score a re-delivery
  after its race closed as a late arrival

(cherry picked from commit 19294fb)
…ed (#2990)

GET /logs/serialized?tail=N on each regional probe returns only the
Serialized monitor's lines: connects, drops, each trade over 3 s with
its hash, `at` and our receive time, and duplicate deliveries. It takes
its own token, SERIALIZED_LOGS_TOKEN in X-Logs-Token, so it can be handed
to Serialized without opening the full log. Lines go to a dedicated
20,000-line ring, because the GeckoTerminal tape turns the shared one
over in under an hour.

Also routes the log package into the capture: it held the original
stderr from init, so every log.Printf line (drops, slow trades,
duplicates, most of the Codex and OKX paths) never reached /logs.

(cherry picked from commit 9aeb4cd)
#2993)

The spec went with a main-only hotfix on 2026-07-26 that also landed on
dev, and the harness was removed as orphaned on 2026-08-17. Both come
back, observational only: the active prober (bench 027, which needs a
funded wallet and three vendor keys and never had data) is dropped.

Re-checking every tip wallet on-chain before restoring found the list
was not what the page claimed:
- 0slot: 5 of 10 addresses had corrupted suffixes and had never
  received a transaction, and its docs list 21 wallets
  (0slot_dot_trade_tipN.sol). Replaced with the 21, all live.
- Jito: one of 8 addresses was corrupted; replaced from the live
  getTipAccounts.
- Helius Sender (10) and bloXroute (17) match their docs exactly.

Dedup was keyed on the signature alone, so a tx tipping two services
(Helius Sender's dual path pays Helius and Jito) went to whichever
notification arrived first. Keyed on (service, signature) it now counts
for each, which is the over-count the page discloses. The spec no
longer claims zero overlaps, nor a log of unattributable tips that the
harness never had.

Runs on the VPS as ocb-solana-tx-landing on the public mainnet-beta
WebSocket: Chainstack rejected part of the 84 subscriptions (-32005)
in a side-by-side test, the public socket held all of them for 3 min.

(cherry picked from commit e02ac72)
* rpc-cost: the three findings the audit left standing (#2821)

* rpc-cost: the three findings the audit left standing

Bench 282 has been gated since 2026-09-30 with two blockers and a major that
nothing had been done about. All three are the same shape: a value that was
never stated read as a value of zero or one, producing a plausible wrong
number that eighteen tests passed straight over.

1. block_age multiplied the whole weighted total.

Chainstack's archive surcharge is triggered by how old a block is, not by
which method asked, and several methods already carry the doubled figure in
their own weight: debug_traceTransaction is listed at 2 RU and the empirical
pass measured 2 RU even at tip-10. Multiplying again charged trace 3.6 RU
against a real 2.0, an 80 % overstatement.

The surcharge raises the floor rather than scaling the bill, so the archive
weight of a method is now max(its listed weight, chain default x multiplier):
a base read goes 1 -> 2, a debug call already at 2 stays 2. The indexer
profile is deliberately unchanged at 2.0, because every method in it is 1 RU
and the old multiplier reached the same answer there; a fix that moved that
number would be over-reaching.

2. The dedicated cohort could not be billed for requests.

needed = 0 for all 37 plans, so the overage and max_units branches were
unreachable. AWS AMB read 97.82 dollars at every volume against a real 1B
bill of 3,097.82, and led every Dedicated cell on it.

The root cause is that included_units: null means two opposite things here.
GetBlock's dedicated node serves unlimited requests for its monthly fee; AWS
AMB includes none and bills every request on top. Both write null. The plan's
own overage rate disambiguates, so that decides rather than the cohort: 4 of
37 meter (AMB in three regions, Shyft legacy-scale), 33 stay flat and their
allowance is read as unlimited rather than zero. Zeeve's published ceilings
now bind too.

3. An unstated capability was read as a granted one.

archive: null and trace: null passed both gates, because one tested for the
string "false" and the other for a non-nil pointer. 27 of the 106 usage plans
are null on each field, and NOWNodes won trace cells on a capability it makes
no claim about. Now not ranked, and the reason distinguishes the two cases:
"does not serve" is the provider's statement, "not published" is a gap in this
catalogue, and only the second is supportable for a null. 13 providers carry
an explicit trace: true, so the cells keep a field: 20, 19 and 13 eligible
plans at the three volumes.

Three published winners move, two of them exactly as the audit predicted by
hand before any of this was written:

  trace @ 10M    chainstack 49      was drpc 60
  trace @100m    chainstack 499     was nownodes 567.75
  trace @  1B    drpc 6000          was nownodes 5677.50 (unstated trace)
  indexer @ 1B   onfinality 3624

Six tests, R5 to R7 from the audit's list. Verified by discrimination rather
than by passing: with the fixes reverted, four of the five model tests fail
and R6 fails, while TestFlatDedicatedPlanStaysFlat passes both ways, which is
what it is for.

The bench stays gated. The remaining audit items are the circular R1-R4 in
the checks plugin, the units and free-allowance columns rendering on 4 of 90
views, and the free tier panel, which is the next piece of work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* pin the exact-fit edge and the unit conversion

Florent asked for a triple check on the values, calling out that Chainstack
bills in RU. Three things came out of it.

The verification standard for this bench is two independent implementations
agreeing on every cell, and I had not re-established it after changing the
model. Rebuilt as a Node scorer reading the same catalogue with js-yaml,
written from the catalogue's field semantics: 2,490 quotes, 0 disagreements
on tier, eligibility, units per request and monthly bill, and all 15 cell
winners identical.

That run also taught something about what independence means here. My first
attempt re-typed the workload profiles into the second implementation and
produced 66 false disagreements, all from two transcription errors in the
dapp and solana-bot mixes. Profiles are INPUT data; retyping them buys no
independence and only adds copying mistakes, so the Go side exports them and
the scorer reads them. The arithmetic is what has to be written twice.

Then the published winners turned out to sit exactly on an allowance.

Chainstack's archive workloads cost 2 RU per request after the block_age
floor, its tiers include 20M / 80M / 200M / 400M RU, and the buckets are 10M
/ 100M / 1B requests. Round times round lands on round: trace and indexer at
10M need exactly 20,000,000 RU, which is exactly Growth, and at 100M exactly
200,000,000, which is exactly Business. Verified by hand, then reproduced by
the scorer.

So 49 dollars is correct and knife-edge. 10.1M requests is 52 dollars, 11M is
79, 15M is 199. Pinned, because a change to the weights, the floor or the
buckets must not move that edge in silence.

And a correction. I reported that the free tier could not be ranked for want
of an allowance metric. Wrong: rpc_free_allowance_requests exists, is
computed by FreeAllowanceRequests, and is live with 53 series. I had queried
{__name__=~"rpc_cost.*"} and the metric is named rpc_free_*.

It matters because converting units to requests REVERSES the raw ranking, and
the raw figures are what I had shown:

  onfinality     400,000 RU/day   ->  12,000,000 requests/mo
  drpc       210,000,000 CU/mo    ->  10,500,000
  ankr       200,000,000 credits  ->   1,000,000

Ankr looks second best in the cohort by allowance and near the bottom by what
it buys, because it charges 200 credits a request against OnFinality's 1 RU.
All three hand-checked and now pinned. The catalogue's own comment says it:
200M credits means nothing until you know a credit buys a two-hundredth of a
request.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 949e0a2)

* free plans tie at $0, so bench 282 was ranking them by their overage bill (#2826)

The cost board banded plans by price and gave free ones their own tab on the
same axis. That axis cannot separate them: every free plan costs the same
nothing, so what the tab actually ordered was the bill each one sends after
the allowance runs out.

That showed the wrong half of the field. Chainstack Developer meters overage
at $20 per million, so a 10M workload put it on the free tab at $140, dearer
than its own paid Growth plan at $49. Alchemy and Infura are hard_stop, so
they served nothing, were ruled ineligible, and left the tab altogether. A
reader comparing free plans saw the one that bills and not the two that stop.

Bench 283 ranks them on the only axis that separates them: how many requests
of a real workload the allowance buys, once credits, Compute Units and
Request Units are divided by what one request costs in them. Fourteen
providers, and the spread is 160x on the Ethereum dapp mix, from OnFinality's
12.0M to GetBlock's 75,000. The order changes with the workload, so the figure
is published per workload and never averaged: an allowance averaged across a
trace mix and a simple-read mix describes no workload anyone runs.

The metric already existed. rpc_free_allowance_requests has been published
since the cost bench was built, with a comment saying the free tier belongs on
its own axis. It had no `all` aliases, so a board pinning kind="all" would
have found nothing: injectLabels only replaces a selector pinned to `="all"`.
The headline slice is now published under those aliases too, as a slice and
not as a pooled average, with three tests pinning that no other slice claims
`all` and that both headline constants name profiles the harness produces.

QuickNode is not on the new board. Its Free trial runs one month and does not
renew, so it is priced on 282 as an entry plan instead.

Gated until the harness is redeployed, since the aliases ship with this
change and the page would otherwise render an empty table.

(cherry picked from commit 8d2e54d)

* the Solana tab was empty, because a workload already belongs to a chain (#2828)

Reported straight after shipping: most of the selectors return nothing.
They do.

                kind=all  simple-read  indexer  trace  solana-bot
  chain=all           11           11        9      5       EMPTY
  chain=solana     EMPTY        EMPTY    EMPTY  EMPTY          13

The board carried two axes, workload and chain, and the profiles only ever
had one. simple-read, dapp, indexer and trace are Ethereum; solana-bot is
Solana. Picking Solana with the default workload asked for an Ethereum mix
on Solana, which nothing publishes, so the table came back empty. Eight of
the ten cells could never have been filled.

The chain axis is gone and the chain is named in each workload label. The
chain alias goes with it: with `chain="all"` aliased onto Ethereum, a query
that pinned kind only would have matched two chains at once and the scalar
read would have returned null, which is the quieter version of the same bug.

All five tabs now carry data: 11, 11, 9, 5 and 13 providers.

A provider missing from a tab is serving a chain that tab does not ask
about, and the methodology says so rather than leaving a gap to read as an
absence.

The test gains the invariant that would have caught this before deploy:
every workload resolves to exactly one chain.

(cherry picked from commit 7b76aee)

* the free-tier board gave dRPC a Solana allowance three lines under the measurement refusing it (#2835)

dRPC's free plan answers error 35, "chain is not available on free plan",
on Solana. That is measured, dated 2026-09-29, and written in the plan's
own note in this catalogue. The board published a 10.5M request Solana
allowance for it anyway.

The gate was not missing. FreeAllowanceRequests already skips a plan whose
archive is false, whose trace is false, or whose declared chains exclude
the workload's, and its comment cites dRPC leading the trace panel as the
reason those branches exist. The chain branch is conditional on the plan
declaring chains, and this one declared none.

Nor does the test every metered cohort leans on catch it: that one lives in
unitsPerRequest, and dRPC prices Solana on its paid plans, so the units
resolve and the free plan rides through on weights it is not entitled to.

A plan-level chains list is the only thing that can say "the provider serves
this chain, this plan does not", so dRPC's free plan now carries
chains: [ethereum].

Two tests. One holds the general rule against the shipped catalogue: a free
plan that declares chains publishes an allowance for no other. One pins the
dRPC row itself, so an edit that drops the line fails here instead of
quietly restoring the figure.

Left open deliberately: ten other free plans declare no chains at all, so
the same hole is open wherever a provider prices both chains and restricts
one on the free tier. Closing it by requiring the field would fail the
catalogue on all ten and take the bench down; closing it by assuming a
default would invent data in a file whose worth is that every line is
measured and dated. Those ten need the probe dRPC got.

(cherry picked from commit 7d237e6)

* a free allowance has no single value, so the board publishes the span (#2837)

The workload tabs were five, and a reader asking which free RPC plan is
biggest had to click all of them and hold the numbers in their head. The
reason the tabs exist is real: providers meter in units, a request costs a
different number of units per method, and BlockPI's one free plan buys 3.1M
simple reads and 368k traces. But asking the reader to discover that by
clicking is the wrong half of the deal.

Each row now carries the two ends of its own span next to the headline:
the least and the most generous workload that plan serves on the selected
chain. The spread is visible without a click, and the seven plans that
meter flat report floor equal to ceiling, which says outright that their
allowance cannot be surprised by a workload. The simple-read tab is gone:
it drew the same eleven plans as the dapp tab in the same order bar one
mid-table swap, because it is that mix's optimistic edge rather than a
different thing to build, and the ceiling column now reports its number on
every row at once. Four tabs, five priced mixes.

Three things had to be fixed to make the columns true:

The weighted mix was summed over a Go map, so the same plan priced
differently from one call to the next and a span computed in a second pass
could exclude the figure it brackets. Mix order is fixed now.

The mean was not exact in the uniform case. Ankr charges a flat 200 units
for every method; averaging share*200 over the seven-term dapp mix returned
199.99999999999994, so the allowance came back as 1,000,000.0000000002 on
one mix and 1,000,000 on the others, and a flat-rate plan was published as
workload-sensitive. A shifted mean makes the uniform case exact.

A 24h range selector outlives its own corrections. The chain alias was
retired this morning, and the dead chain="all" series went on answering for
21 more hours: every headline scalar saw two series where the loader wanted
one and returned null on all 14 rows, and dRPC's Solana allowance, whose
fix shipped in #2835, kept being published by the shadow. The scalars look
back 3h now, matching the freshness the page promises and the harness's
hourly cycle, and they aggregate, so the next label change costs nothing.
The 24h window stays where it belongs, on the chart.

(cherry picked from commit 1d7c512)

* the Solana tab crowned the one row the catalogue refuses to rank (#2840)

Solana Foundation led the Solana workload at 25.92M, more than double
OnFinality, above eleven plans with published allowances behind them. Its
own catalogue entry says the figure is DERIVED, not published: 10
requests/second integrated over 30 days, a throughput ceiling on an
endpoint Solana's docs call "not intended for production applications". The
entry carries ranked: false and two caveats saying exactly this, and the
methodology printed under the table says keyless public endpoints with no
published allowance are excluded.

Nothing read any of it. `ranked` was wired into the cost metrics' cohort
label and never into FreeAllowanceRequests, so the page contradicted both
the catalogue and its own stated method, and the contradiction sat at the
top of a tab. The cost benches can show a reference row beside the ranked
ones because they have other columns; here the allowance IS the ranking, so
a row that cannot be ranked has nothing to show. 13 plans, 11 per chain.

Two figures shown next to provider names were also wrong, found by checking
every tag against the catalogue:

OnFinality was tagged "25M OnFinality credits a month". It is 400,000
response units a DAY, verified against onfinality.io today, which is 12M a
month and the number the board was already computing. The tag invented both
the value and the unit name, on the row leading three of four tabs.

BlockPI was tagged "100 RU/s and a daily cap". Its rate is 400 RU/s and its
free tier is neither daily nor capped: a 50M RU package issued on the 1st
that expires after 32 days and does not accumulate, pro-rated here to
46.875M over 30 days. The methodology bullet repeated the same error by
listing BlockPI among the providers whose daily figure is multiplied by 30.

Verified in passing, and correct: Alchemy's 30M CU against its live CU table
(eth_call 26, getBalance 20, blockNumber 10, receipt 20, estimateGas 20,
sendRaw 40, getLogs 60) gives 23.9 CU on the dapp mix and 1,255,230
requests, the published figure to the unit, with simple reads and the
indexer mix also exact. dRPC's flat 20 CU per method and Chainstack's 1 RU
full / 2 RU archive both confirmed against their docs.

(cherry picked from commit 2454768)

* bench 282 priced three volumes, and every reader is at a fourth (#2927)

Bench 282 prices 10M, 100M and 1B requests a month, because a Prometheus
series needs a fixed label. Nobody runs 10M requests a month on purpose, so a
reader at 40M or 3B had to pick the nearest column and guess which way the
error ran, and on a metered bill with allowances and overage bands it does not
run one way.

The harness now emits a cost curve and the page interpolates between its
points. The arithmetic is not reimplemented: the slider and the gauges both go
through cheapest(), one at emit time and one at scrape time, so they cannot
drift into disagreeing about what a provider charges. Interpolation is exact
rather than smoothed, because inside one plan and one overage band the bill is
affine in the request count and the emitter puts a point at every corner.

Finding those corners took three tries, each drawing a straight line across
something that is not straight: the handover between two plans happens where
their lines cross, a corner of neither (Chainstack $83.83 at 10M against $49);
the dedicated cohort steps $150 to $300 by adding a node while the plan id
never changes (GetBlock $295 against $150); and thinning identical points
collapsed a flat run to its first point, deleting the corner where the
allowance runs out. The test is the artifact against the model at the three
published volumes, every provider, every workload: 246 priced points within a
tenth of a percent. 1,338 points, 158 KB raw and 13 KB gzipped.

Three findings came out of asking the model at volumes nothing had asked
before, all of them a $0 that was not a price.

The curve first carried all 43 catalogue entries, including the twelve the
board drops as not comparable, which would have put 1RPC, POKT, Lava and
Tenderly on a slider sitting above a ledger that does not list them.

Three plans cost $0 because they end: QuickNode's one-month trial, NOWNodes'
one-month Start, Tatum's lifetime 100k credits. All three are banded as paid
plans by what they become, with a tier_note saying in so many words that they
do not recur, and the model read the $0 anyway. At 10M a trial is already past
its cap, so nothing surfaced it; at 100k both one-month trials led the board.
Plans now carry non_recurring. The guard is recurrence, not the zero:
Validation Cloud's Scale has no subscription and its first 50M CU are free, so
it still reads $0.

Syndica, Triton and Coinbase CDP told readers nobody publishes a unit cost for
eth_blockNumber. They carry `ethereum: null` under weights because the chain is
not served, the key exists so the lookup succeeded, and the first method then
failed for want of a weight. Right verdict, wrong branch, and a log line until
the curve started printing each reason beside a provider's name.

No published figure moves: all 435 readings the three gauge volumes cover,
across every provider and all five workloads, are identical before and after.

pnpm validate now fails when the artifact falls behind the catalogue it was
built from, and names the command that fixes it.

Bench 282 is still in DEV_ONLY_BENCH_SLUGS, so this is staging only.

(cherry picked from commit c56ce58)

* audit of benches 282 and 283 against the providers' own docs (#2930)

A deep audit of benches 282 (`rpc-cost`) and 283 (`rpc-free-tier`) against each provider's own documentation. The arithmetic came out clean; the **inputs and the prose did not**. Seven published figures were wrong, one of them on production.

## The model is sound

- **78 of 78** live `units_per_request` values reproduce exactly from an independent Python re-implementation reading the same catalogue (profiles parsed out of `profiles.go`, never retyped — retyping produced 66 false disagreements last time). This re-establishes the two-implementation standard after the model changes of 2026-10-09.
- **BlockPI's $29.50**, the default view's leader, rebuilt from its live docs: all eleven Ethereum RU weights verbatim, `$49 / 500M RU / 60 days` verbatim, `$0.01 = 50,000 RU` verbatim. 27.5 RU/request re-derived by hand; `27.5 x 10M = 275M RU`, less 250M pro-rated, 25M overage at $0.20/1M = **$29.50**.
- **Chainstack's trace = 2 RU** confirmed against the live doc's "Always 2 RUs" section: `debug_*` and `trace_*` are 2 RU *regardless of block age*, so there is no path to 3.6 or 4.
- **Alchemy's 23.9 CU/request** matches the live CU table method by method; `30,000,000 / 23.9 = 1,255,230`, bench 283's figure.
- **NOWNodes $102.19** and **Tatum $287** both reconstruct from their live pricing APIs.
- **All 435 cost readings** across the three published volumes are byte-identical before and after everything in this PR.

## Seven wrong published figures

**1. On production: nine pages quote an Alchemy limit Alchemy retired in August.** Every keyed RPC spec disclosed "a Free plan key (30M CU per month, **25 requests per second**)". Our own catalogue quarantines that exact figure: *"www.alchemy.com/pricing still renders the stale '500 CU/s (25 rps)' and must not be published"*, citing `alchemyplatform/docs` commit `3e0a4485`, 2026-08-24. Live today: `| Base Throughput (CUs/second) | 300 |`. Replaced with Alchemy's own unit; a converted request rate depends on the method mix, which is bench 282's entire subject. **These nine are on `main`.**

**2-5. Bench 282 credited NOWNodes with trace wins it was removed from on 2026-10-05.** Recomputed every cell from the live gauges:

| the spec said | live |
|---|---|
| trace@100M "6.7x, from NOWNodes at $567.75" | **7.6x, Chainstack $499** → GetBlock $3,779 |
| "NOWNodes … winning the largest trace cell" | NOWNodes wins **no** trace cell |
| "dRPC … 5.7% above NOWNodes" | dRPC wins **outright** |
| FAQ "NOWNodes and Syndica two each" | **Chainstack** and Syndica two each |

Check R1 went green on the last one: it compares the *count* of outright winners and the maximum, never the names.

**6. "21 artifacts are re-hashed on a loop"** in four places. 21 carry a baseline hash, but `checkFreshness` skips excluded providers and the 21st is Lava, `cohort: excluded`. 20 are polled, matching the 20 live `rpc_pricing_artifact_ok` series.

**7. AWS published a break-even volume at which it costs twice the alternative.** AWS AMB bills `$97.82 + $3.00/1M` against an Ethereum floor of `$2.833/1M`: its own rate is *above* the floor, so it is dearer at every volume and the gap widens. The published figure was **34,528,768 requests**, where AWS bills $201.41 against $97.82 metered. The tell was visible in the data: the three AWS regions meter at $3.00, $3.41 and $4.81 and all three published the *same* break-even, because the formula never read the rate. Now solved piecewise — flat region `fee/floor`, metered region `fee/(floor − rate)` — and where no crossing exists the harness publishes nothing and logs why. **40 series → 37.** The 33 flat plans are untouched; Helius dedicated still reads 2,495,594,714 against a live 2,495,590,000.

## Two free plans ranked on workloads their own pages refuse

Bench 283's trace tab carried five rows. Two providers publish plainly that they do not sell what was being ranked:

- **Ankr**: the feature table reads `Trace ❌ ❌ ✅` and `Debug ❌ ❌ ✅` across Public/Freemium/Premium. The catalogue had `freemium: trace: true`, so Freemium was published **second** at 1,000,000 requests/month.
- **Instanodes**: the Free card lists, under "Not included", "Archive node access". The catalogue had `archive: null`, and null is a no-op in both gates, so it was ranked on the **indexer and trace** tabs at 600,000.

The same table corrects Ankr in the other direction: `Full and Archive data ✅` in all three columns, so Public *does* serve archive — the catalogue said `archive: false` with the note "NOT PUBLISHED", wrong twice over.

Measured against the corrected catalogue:

| tab | before | after |
|---|---|---|
| trace | 5 rows | **3** — Chainstack 1.5M, BlockPI 367,936, GetBlock 14,423 |
| indexer | 9 rows | **8** — Instanodes out; Ankr stays, it does have archive |
| dapp (default) | 11 rows | **11, unchanged** |

Also: bench 283 promises it reads "only the rows whose monthly price is zero **and whose allowance renews**". The second half was true only by convention — the three non-recurring offers are banded `entry`, so `TierFree` happened to exclude them — while `periodScale("lifetime")` returns 1, so a one-off grant banded `free` would have published as a full monthly allowance. `FreeAllowanceRequests` now skips `NonRecurring` explicitly.

And its `seo_intro` claimed Validation Cloud "meters flat … floor is their ceiling". It prices `eth_call` at 40 CU and `eth_getLogs` at 80; live floor/ceiling is 1,219,512 / 1,250,000 (ratio 0.9756, against exactly 1.0000 for the four that really are flat). The spec's own provider tag says "weighted per method" four lines below.

## Findings left open, not fixed here

- **9 of 20 watched artifacts are in drift**, two of them under the headline $29.50. Verified: BlockPI's are **cosmetic** — GitBook injected an `llms.txt` preamble and an 18-line "Agent Instructions" footer on every page, with no priced value changed. Chainstack's and Alchemy's are non-price too. The hashes need re-baselining, ideally with normalisation, or the alarm trains everyone to ignore it — which is the exact failure the methodology says it avoids for the manual sources.
- **Two of four metric panels are structurally empty.** `rpc_cost_units_per_request{kind="all"}` → **0 series** (the harness emits only the five profile ids; `freeAllowance` beside it uses `aliasesFor`), and `injectLabels` appends `bucket=`/`venue=` to a metric that has neither, so it fills on 4 of 75 views. `rpc_cost_breakeven_requests` carries no `kind`/`bucket`/`venue` at all → 0 on the default view, and `prom.scalar` returns null on multi-series anyway. The methodology promises units-per-request "so the arithmetic is checkable".
- **Tatum's two load-bearing inputs no longer exist at their citation.** `$9/1M credits` (which is $108 of the published $287) and `allowance_period: lifetime` are both cited to `tiers.json`; `0.000009`, `per credit`, `overage` and `lifetime` are all absent from the live file, whose FREE object was edited 2026-10-06T07:07:10Z. Neither is necessarily wrong; neither is checkable where we say to check it.
- **The bench 283 podium rests on unwatched sources**, and OnFinality's 12M assumes a unit weight of 1 on Ethereum while its own docs list Ethereum among the chains with "higher Response Unit weightings for more intensive methods", unpublished. That makes 12M a ceiling, and the indexer tab is `eth_getLogs`-dominated.
- **5 of bench 283's 11 ranked plans are `confidence: uncertain`, including the whole podium**, and no confidence signal is published for any of them.

## Checks

- `go vet`, `go test ./...` clean, 4 new break-even tests (the old formula's 34,528,768 pinned as the regression case, plus a catalogue walk that fails if a fifth metering plan appears unreviewed)
- `bun test` 25 pass; `pnpm validate` ✓ 246 specs; `pnpm typecheck` clean
- `src/data/rpc-cost-curves.json` regenerates byte-identical, which is itself evidence the paid board does not move

Bench 282 and 283 are both in `DEV_ONLY_BENCH_SLUGS`. The nine chain RPC specs in commit 1 are **not** — that correction is live-facing and needs a cherry-pick to `main`.

(cherry picked from commit e3dd32b)

* two blank panels, OnFinality's published weights, and the last four logos (#2940)

OnFinality publishes the weights we said it did not, and it was leading on them

The catalogue gave every OnFinality method a weight of 1, with a caveat
saying the real multipliers are unpublished: /support/response-units states
the default is 1 "across the majority of our networks" and names Ethereum and
Solana as exceptions "with higher Response Unit weightings for more intensive
methods", without giving them.

The numbers are one click away. Those network names are links. The index at
/support/networks promises "supported methods and response unit weighting",
and /support/ethereum carries a table headed "The current supported method
set and their Response Units weighting". Read live and verified here:

  eth_call 1   eth_getBalance 1   eth_blockNumber 1   eth_getBlockByNumber 1
  eth_getTransactionReceipt 1   eth_getLogs 3   eth_estimateGas 3
  eth_sendRawTransaction 12   eth_getBlockReceipts 13
  trace_block 2   debug_traceTransaction 30

So the dapp mix is 1.75 RU per request and the indexer mix is 5.00, not 1.
The caveat called those cells a LOWER bound. They were an upper one.

What it moves, measured by running the harness against the corrected table:

  bench 283, the free-tier board
    dapp      OnFinality 12,000,000 -> 6,857,143, and dRPC takes the lead
    indexer   OnFinality 12,000,000 -> 2,400,000, a 5x overstatement
    simple-read unchanged at 12,000,000, which is the one tab the old
    figure was right on, and the one the "160x" finding rests on
    solana-bot OnFinality leaves the board entirely

  bench 282, the cost board
    OnFinality goes from four outright cell wins to none
    new tally: BlockPI 5, Chainstack 5, dRPC 2, Syndica 2
    four winners, not five; the most any one takes is five, not four
    one tie cell at $49 (Chainstack, Helius), not two
    trace@100M now spans 13.7x, Chainstack $499 to OnFinality $6,849

Every sentence that quoted those facts is rewritten from the live gauges: the
seo_intro's winner count, finding 1's spread, finding 4's claim about which
figures are fully published, and the FAQ's per-provider tally. Bench 283's
"meter flat, floor is their ceiling" list drops OnFinality in both places it
appears — it now has the second-widest span on the board, 2.4M to 12M.

Solana needed a judgement. All 60 methods in the published table weigh 2 and
the page declares no default, so `default` is null rather than 2. And
sendTransaction is absent from the table altogether, which reads as
unpriceable rather than as 2 — the same rule Tatum gets on archive and
NOWNodes gets on trace. That is what removes OnFinality from the Solana bot
profile; a weight the provider has not published is not one we may invent. If
the row appears, the cell returns on its own.

Three free-tier confidence flags also come off `uncertain`, each on a primary
source rather than a judgement:

  onfinality/developer  the cap behaviour IS published — HTTP 403 and WS
                        -32403 "Daily response limit exceeded", plus
                        "Additional Response Units: Not provided"
  chainstack/developer  25 RPS on three primaries; the lone 30 is a Warning
                        aside in a billing-procedure page, still live, now
                        named in the note instead of leaving the row hedged
  instanodes/free       both caps on three surfaces, and the hard stop is
                        sourced: a 429 at the tier ceiling with no overage
                        rate published anywhere

Two stay uncertain and should: dRPC publishes nothing about what happens on
exhausting its 210M CU, and Blockdaemon's own docs disagree with themselves
about the free allowance, 3M in an April page and an archived pricing card
against 1M in the October plan table. Both notes now say exactly what is
missing rather than hedging the whole row.

Also here, the four providers that still rendered as initials chips. BlockPI
mattered: it leads the cost board and was the only leader without a mark.
syndica.png and triton.png turned out to have been in public/logos all along
and simply never registered, which is a reminder that dropping the file is
half the job. All four take the provider's own glyph as SVG.

(cherry picked from commit 0a678fd)

* the break-even query could not parse, Best $0 held the strip, and a price is not a distribution (#2943)

the break-even query I shipped yesterday cannot parse, and the strip still said Best $0

Two findings from re-auditing bench 282 after the data fixes landed. The
first is mine, from the day before.

**#2940's break-even metric does not parse.** I changed it to
`min by (provider) (rpc_cost_breakeven_requests)` so the panel could collapse
a provider's eleven plan series into one, and I tested that string against
Prometheus, where it works. The page does not send that string. The loader
splices the provider filter at the metric's FIRST brace and, when there is
none, appends it to the end:

  min by (provider) (rpc_cost_breakeven_requests){provider="zeeve"}
  -> 1:48: parse error: unexpected "{"

An empty selector inside the aggregation fixes it, verified the same way:

  min by (provider) (rpc_cost_breakeven_requests{provider="zeeve",})
  -> 1 series, 52,594,423

I verified the mechanism instead of what the page asks, which is the exact
failure this bench keeps finding in itself. So `pnpm validate` now refuses a
panel metric that aggregates without carrying a selector, and I checked the
guard by reverting the fix and watching it fail.

**"Best $0" still heads the default view.** Blockdaemon publishes no paid
price at all — its three paid plans are confidence: unpublished — so it has
no headline result. The load path promotes it to "live" on the strength of
its companion panels, and `computeFieldStats` kept it because bench 282 sets
`zero_is_a_value`.

That flag says a zero CAN be an answer. It does not say every zero is one.
With the all-zero guard switched off, the strip printed Best "$0" above a
ledger whose leader is BlockPI at $29.50, and the Spread printed "-" because
tailMin had become 0. The ledger itself never showed the row: it drops at the
5% success floor. Two halves of one page disagreeing again, with the wrong
half on top.

The test is now whether the row was READ, not whether it came out zero. A
measured zero keeps its success rate and stays, which is what the flag
exists for — Validation Cloud's Scale has no subscription and its first 50M
compute units are free. An unmeasured one has no success rate and goes, the
same treatment the ranked surfaces already give it.

Five tests pin both directions, including the measured zero.

(cherry picked from commit 7bcf2c8)

* Helius said its price was sales-gated, the free-tier panel was blank, and BlockPI was 6.25% low (#2952)

Three majors from the bench audits of 282 and 283. Two are a page saying the wrong thing; one is a wrong number.

## Helius said its price was sales-gated. It publishes a $49 plan.

The slider block and the harness log both read `not ranked (price not published (sales-gated))`. Helius publishes Developer at $49; the real reason it carries no Ethereum bill is that it has **no Ethereum weights at all**.

The order was wrong. `quote()` tests `confidence: unpublished` before it reaches the chain test inside `unitsPerRequest`, and `cheapest()` then keeps the reason belonging to whichever ineligible plan had the largest allowance. A provider that simply does not serve the chain reported a price problem.

Whether a provider serves the chain is a **provider** fact and outranks any plan-level question about price, so `cheapest()` now asks it once, up front, for the usage cohort. The dedicated cohort is exempt: it bills whole requests with no method weights and keeps its own chain test in `quote()`.

```
helius, syndica, triton, coinbase-cdp  →  "provider does not price ethereum"
blockdaemon                            →  "price not published (sales-gated)"
```

Blockdaemon's sentence is unchanged because for Blockdaemon it is true: it has Ethereum weights and all three paid plans are `confidence: unpublished`. Both are now accurate, which is the whole job of a panel that exists to tell a missing price apart from a cheap one.

## The free-tier panel was blank on 70 of 75 views

Same cause as the two panels fixed in #2940 — and I fixed those two and left this one. `rpc_free_allowance_requests` carries provider, plan, kind and chain, so appending `bucket` or `venue` matches nothing. A free allowance does not change with how many requests you intend to buy or which paid band you would land in, which is the point of ranking it separately.

## Every BlockPI figure on bench 283 was 6.25% low

The free plan was `allowance_period: package_32d`, so 50,000,000 RU was pro-rated to 46,875,000 for a 30-day month. **The 32 days is how long one package stays valid, not how often one arrives.** The catalogue already quoted the sentence that settles it: *"a gift package distributed to registered users on the 1st of every month ... expires after 32 days, does not accumulate"*. A calendar month delivers 50M; the overlap is forfeited, not carried. Strict pro-rating would not have given 46.875M either — twelve packages over 365 days is 49.3M per 30.

| | before | after |
|---|---|---|
| dapp | 1,704,545 | **1,818,182** |
| simple-read (ceiling) | 3,125,000 | **3,333,333** |
| trace (floor) | 367,936 | **392,465** |
| Solana | 721,154 | **769,231** |

The spec's quoted figures move with them (`3.1M against 368k` → `3.3M against 392k`); the factor of eight still holds at 8.49.

## Effect, measured

Eleven of the 435 published cost readings differ from the 2026-10-09 baseline, and **all eleven are OnFinality** — the published-weights correction from #2940 flowing through the dapp and indexer mixes and withdrawing it from solana-bot. The BlockPI period fix moves **zero** paid readings: it belongs to the free plan, and the paid packages keep their own `package_60d` / `package_90d` pro-rations. BlockPI still leads bench 282 at $29.50.

536 tests, 246 specs, `go vet` and the harness suite clean.

(cherry picked from commit d6b85b4)

* rpc-cost: Dwellir is priced, Instanodes leaves, Validation Cloud is one product (#2996)

Dwellir bills one credit per response on every method, archive, debug and
trace included, where Chainstack bills the same archive read and every trace
call at 2 RU. Priced from dwellir.com/pricing.md (2026-10-16), it leads 7 of
the 15 cells of bench 282 outright and ties Chainstack at $49 on two more:
$1,999 for a billion trace requests against dRPC's $6,000 and Chainstack's
$8,990. Its Unlimited Nodes and the Ethereum archive cluster join the
dedicated cohort as dwellir-unlimited, marked uncertain because the premium
endpoint add-on is unpriced.

Its Free plan excludes eth_getLogs while the paid plans serve it, which the
per-provider weights table cannot say. Plans gain unserved_methods: a
workload calling one is unavailable on that plan, on the cost board and in
the free allowance. On bench 283 that leaves Dwellir's free plan with no
workload to rank, and the methodology says so instead of showing an empty row.

Instanodes and the defunct InstantNodes are removed from the catalogue, both
specs and the registry; the prose counts move with them.

validation-cloud folds into validationcloud, so the latency and price pages
are one product page, and the logo is the square mark Validation Cloud
supplied, redrawn as a vector. The unused 256 px PNG goes.

(cherry picked from commit 75a0e29)

* products: the five bench-282 plan slugs fold into their vendor's page (#2997)

dwellir-unlimited, getblock-dedicated, chainstack-dedicated,
quicknode-flat-rate and helius-dedicated are plans priced in their own
cohort, not products. Each built a /products page beside the vendor's.
They now 308 to the vendor and their appearance lands on its page.

(cherry picked from commit df3fa75)

* rpc-cost opens on one slider and an archive checkbox (#3000)

Four rows of selectors (cohort, plan band, volume, workload) over a
three-volume ledger made the reader assemble the answer. The page now
opens on the calculator: slide to a monthly volume, tick archive if the
calls read old state, and it names the cheapest plan that serves it,
metered or flat rate, then ranks the rest.

The archive curve is a new curve-only profile (the dapp mix with every
call billed as archive): no gauge, no tab. The curve file now carries
the catalogue's plan names, and the catalogue's as_of moves to the
Dwellir edit.

(cherry picked from commit b3ac9de)

* rpc-cost: the calculator gets a chart and clickable rows; AWS and Google leave (#3002)

AWS Managed Blockchain and Google's Blockchain Node Engine rent a node by
the hour inside a general cloud rather than sell an RPC service, and both
are closing. They move to the excluded cohort with the reason written,
leave the spec and the registry, and a finding says why.

The calculator: a recent-state / archive switch, the cheapest plan in an
accent card that links to its product page and says what it saves over the
next plan, a log-log SVG chart of the bill against volume (the cheapest
plan at every volume plus the four runners-up, a shaded band where a free
plan covers the month, click or drag to move the volume), and ranked rows
that link to each product page with a log bar and the multiple of the
cheapest. SVG coordinates and CSS widths are rounded so server and client
print the same attribute.

(cherry picked from commit 7443478)

* rpc-cost audit fixes: per-workload grid, honest copy, panel carry guard (#3006)

From the 2026-10-16 bench audit (verdict FIX THEN SHIP; every cell figure
and all 15 winners recomputed and matched).

- The page lost every Solana, indexer and trace figure when the selectors
  went. A fixed grid under the calculator gives the cheapest metered plan
  for each of the five workloads at 10M, 100M and 1B: the fifteen cells
  the copy counts (Dwellir 7, BlockPI 3, Syndica 2, three ties at $49),
  each linking to its product page.
- Calculator copy said what the code does not: no findings are rendered,
  free plans are not in the list (paid only, bench 283 has them), and flat
  rate nodes can undercut the metered leader. Rewritten.
- Spec prose described a ledger, a profile picker and an age display that
  do not exist; 20 providers and 20 polled artifacts were 18 and 23;
  Blockdaemon (sales-gated) and Coinbase CDP (Base only) leave the provider
  list since neither can fill a cell.
- Worker: panel values were carried with neither the declared-slug guard
  nor the age ceiling the result carry has, so Instanodes' price and two
  Ethereum-only nodes' Solana break-even outlived their removal. Tests.
- Product pages said 'data warming up' for Syndica, a two-cell winner,
  because the default view is Ethereum. They now say there is no Ethereum
  plan.
- Harness: a body that fails mid-read no longer reports healthy, and a
  block_age rule of 0 is refused at load.

(cherry picked from commit 1515c96)

* rpc-cost: the nine drifting pricing artifacts re-read (#3007)

Eight of nine changed in form only (new rows or sections, a markdown table
turned HTML); every figure the catalogue uses was checked against the live
page and matches. Hashes and verified_at updated so the drift alarm reads
clean again.

Tatum's tiers.json dropped its overage rate on 2026-10-12: the $9 per 1M
credits is no longer published anywhere we can read. It stays as the last
published figure and the 13 paid plans are marked uncertain. NOWNodes'
caveat on Enterprise overage corrected (0.5 EUR per 100k, which the
figure already used). No published number moves.

(cherry picked from commit 03af352)

* rpc-cost audit round 2: no rank for the reference cohort, unverified prices never lead (#3008)

- A tier the spec declares ranked: false takes no rank, win, badge or
  JSON-LD position. PublicNode was '#1 of 1' with a shareable badge on its
  product page under a methodology that says it is never ranked. Its card
  now shows its $0 with 'not ranked' (zero_is_a_value honoured).
- The curve carries each provider's unverified plan ids. The calculator's
  headline and chart take the cheapest VERIFIED plan; Zeeve, Dwellir
  Unlimited, Validation Cloud Scale and Tatum stay listed with an
  'unverified' badge, and the card names a cheaper unverified price when
  there is one.
- Copy: drift is a maintainer alarm, the page reads a committed curve
  dated in its footer (abstract, methodology, README and harness comments
  said the page showed drift and age). Chainstack's debug/trace rule
  stated right, a chain-scope bullet, 'cheapest metered' on the trace
  finding, the calculator's reasons scoped to the dapp mix.
- The 282 gate comment restated: its three original conditions are met.

(cherry picked from commit 00ff87e)

* rpc-cost: calculator without the chart, three missing logos, audit round 3 (#3010)

Design: the chart is gone. The calculator is one card: controls on a
tinted band (volume in large type, the 10M/100M/1B marks as pills, the
archive switch), then the cheapest verified plan with its next plan, the
spread across plans and a product link, the next three as cards, and the
rest as a compact list. Every row has a mark: a logo, or the provider's
initial when there is none.

Logos: Allnodes (its app icon), Shyft and Zeeve (marks cropped from their
own wordmark SVGs; Zeeve on white so its dark dot survives the dark theme).

Audit round 3:
- sample_size counted 7 days of a series the harness withdrew, so a
  provider removed from a cell read 'unresponsive, 0% success' for a week
  (OnFinality on the Solana tab). It now uses the p50's 20 min window.
- The dedicated cohort is ranked: false like the reference one: it ranked
  unverified flat-rate prices and gave Dwellir a '#1 finish' on $119.
- The break-even panel pooled Ethereum and Solana into one minimum; it is
  API-only and wrong on every Solana tab, so it is removed.
- The metric names its slice (dapp mix at 10M), so a product page rank is
  not read as the bench's verdict.
- min(last_over_time(...)) is a latest value, not a 24 h median, and USD
  under $1,000 prints cents as two digits ($29.50, not $29.5).
- Copy: per-call unit weights stated as such, plan bands say base fee,
  free-allowance absence explains Dwellir.

(cherry picked from commit da1ca1e)

* release: bench 282 rpc-cost goes to production

Ungated after three audit rounds on 2026-10-16 (every published bill and
all 15 cell winners recomputed and matched). Its gate conditions of
2026-09-30 are met: the mislabelled selector is gone, the GetBlock
comments were corrected, Chainstack trace is 2.0 RU.

Bench 283 rpc-free-tier stays off main: its spec, carried by the
cherry-picked split commit, is removed here.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
main and dev had diverged both ways: 180 commits on dev, 87 on main, and no
fast-forward since October. A dev to main pull request would have opened
with 32 conflicts and no reviewer able to say which side was right without
reading all of them. This merges main into dev first, where the resolution
can be validated on staging, and leaves the release itself trivial.

Every file resolved in dev's favour was checked rather than assumed:

  the nine keyed RPC specs   main's corrections are already there (the
                             Alchemy figures match on all nine), dev adds
                             Validation Cloud, the p95 and the WebSocket arm
  the recording rules        dev holds all 11 of main's and 12 more
  rpc-capabilities config    dev carries the repair main never got, #2935,
                             which replaced four endpoints that had stopped
                             answering; taking main would restore them
  the Hyperliquid family     10 commits on dev against 5, every file larger,
                             and 34 metric names queried against 32
  katana, xrplevm            dev has one more provider each
  the three registries       no entry exists only on main
  stakeangle.png             dev's is the optimised one from #2938

Two were not a side to take:

  bench-published.json       the union. It records when a page first
                             appeared, so main's six dates for benches dev
                             has since removed are as true as dev's eleven
                             new ones. Earliest date wins where both know a
                             slug.
  provider-registry.ts       dev's entries, main's types. The triage had
                             compared entries and called them identical,
                             which they were; the module's exports were not.
                             main also landed ProviderClaim and an optional
                             claims field, and lib/provider-claims.ts, its
                             test and a product-page call site came through
                             the merge and failed to compile against dev's
                             registry. Caught by tsc, not by reading.

Gates on the merged tree: 253 specs valid, tsc clean, eslint 0 errors,
594 tests, promtool 23 rules, go build and vet on rpc-keyed-latency,
rpc-capabilities, hl-fills-reduce and hyperliquid-frontends, and a full
next build generating 293 static pages.
@Flotapponnier
Flotapponnier merged commit 621b513 into dev Oct 17, 2026
2 checks passed
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