feat(validator): ask the miner for attestation, not the operator - #121
wallscaler wants to merge 1 commit into
Conversation
Today every validator fetches one signed weight vector from Cathedral's endpoint
and checks its signature. Cathedral is therefore the only party that can verify
anything, and its uptime is everyone else's dependency: that endpoint returned
502 for several hours on 2026-08-12 and every following validator stalled with
nothing to write.
Nothing in the miner protocol required that. MinerClient.collect_evidence(nonce)
already exists in cathedral-compute and its own docstring calls it "the
validator's miner protocol". Miners have always been able to answer a validator
directly. Only Cathedral's epoch loop ever asked.
This adds the asking. discover() reads miner addresses from their on-chain axons,
nonce_for() issues a challenge, and collect() obtains evidence over the miner's
own TLS channel. Cathedral is not in that path, so Cathedral being down is not an
outage.
Three properties of the challenge, each load-bearing:
unique per validator two validators challenging one miner in the same epoch
issue different nonces, so a miner cannot answer one
with the proof it gave another
epoch-bound a miner cannot precompute answers for epochs it has
not reached
publicly reproducible all three inputs are public, so anyone can recompute
the challenge a validator was obliged to issue and
check that it did
That last one matters beyond replay: it makes verification work auditable rather
than asserted, which is the only lever Bittensor offers against a lazy validator.
Discovery reads the chain and takes no other argument, enforced by a test. An
operator-held address book would reintroduce exactly the dependency being
removed, because whoever serves the list decides who gets verified and therefore
who earns.
Every failure ends in "this miner earns nothing", never in "use something else".
A test asserts the module cannot reference a signed feed at all, because a
fallback appearing here later is precisely what would turn one endpoint's outage
back into everybody's.
Scope is collection and discovery. Nothing here scores or writes; wiring is
issue #120. Verified against Finney: discovery returns the 1 of 256 neurons
currently publishing an axon, which is itself the finding that miners must
publish one before any of this reaches them.
12 tests, ruff clean.
|
Bugbot is not enabled for your account, so this pull request was not reviewed. Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 17b3217188
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| b"cathedral-direct-attest-v1", | ||
| validator_hotkey.encode("utf-8"), | ||
| miner_hotkey.encode("utf-8"), | ||
| str(int(epoch)).encode("ascii"), |
There was a problem hiding this comment.
Use an unpredictable per-request attestation nonce
Because the validator hotkey, miner hotkey, and future epoch numbers are all public, a miner can calculate every future nonce and pre-generate the corresponding evidence while it is in a valid state, then replay that evidence when challenged after its state has changed. Including a predictable epoch number prevents cross-epoch reuse but does not provide the promised anti-precomputation or freshness guarantee; use fresh randomness or an unpredictable finalized-chain value and record the issued nonce separately for auditing.
Useful? React with 👍 / 👎.
| return evidence | ||
|
|
There was a problem hiding this comment.
Reject all empty evidence responses
When collect_evidence() returns an empty bundle such as [], {}, or b"" rather than None, this check treats the miner as successful and collect_all() places it in the evidence map. That contradicts the documented fail-closed behavior and makes an all-empty response set look like miners answered, so supported zero-length evidence representations should be classified as failures here.
Useful? React with 👍 / 👎.
|
|
||
| @property | ||
| def url(self) -> str: | ||
| return f"https://{self.ip}:{self.port}" |
There was a problem hiding this comment.
Bracket IPv6 axon addresses in URLs
When a miner publishes an IPv6 axon address, this produces a URL such as https://2001:db8::1:8443, whose host and port cannot be parsed correctly because IPv6 literals in URLs require brackets. Format IPv6 endpoints as https://[2001:db8::1]:8443 so those miners remain reachable.
Useful? React with 👍 / 👎.
First code toward #120. Makes it possible for a validator that is not Cathedral to obtain proof of miner work without asking Cathedral for it.
The problem this starts fixing
Every validator today fetches one signed weight vector from Cathedral's endpoint and checks its signature. Two consequences:
api.cathedral.computerreturned 502 for several hours on 2026-08-12 when a TLS certificate expired, and every validator following the feed stalled with nothing to write.What made this small
The miner side already exists.
MinerClient.collect_evidence(nonce)is in cathedral-compute today, and its own docstring calls it "the validator's miner protocol". Miners have always been able to answer a validator directly against a 32-byte challenge. Only Cathedral's epoch loop ever asked.So this is validator-side only. No miner protocol change.
What this adds
discover(metagraph)nonce_for(...)collect(...)collect_all(...)Three properties of the challenge, each load-bearing:
nonce.NewNonce(attester.Hotkey.Address),internal/validator/callbacks/cvms.go).Two deliberate constraints, both enforced by tests
Discovery reads the chain and takes nothing else.
test_discovery_reads_the_chain_and_nothing_elseasserts the signature is exactly{metagraph}. An operator-held address book would reintroduce the dependency being removed: whoever serves the list decides who gets verified, and therefore who earns.No fallback, ever. Every failure ends in "this miner earns nothing", never "use something else".
test_nothing_in_this_module_can_reach_a_signed_feedasserts the module cannot even referenceweights/next,publisher_urlorapi.cathedral. A fallback appearing here later is exactly what would turn one endpoint's outage back into everybody's.Verified against Finney
That result is itself the finding. Only 1 of 256 neurons publishes an axon, and it is the burn hotkey. Miners must publish an address before any validator can reach them. That is the standard Bittensor mechanism and a miner-side action, not a permission Cathedral grants, but it does mean this path reaches nobody until miners serve axons.
Scope
Collection and discovery. Nothing here scores or writes weights. Wiring it into the audit path is #120.
Not addressed here, and tracked in #120:
ATTEST_RATEdefault.95,internal/validator/setup/setup.go:97) and caches verified nodes within an interval, with the comment "helps reduce stress on cvm nodes from number of pings". That amortization is what makes N validators x M miners x K machines tractable and should be copied before this runs at scale.Tests
12, all passing, ruff clean, no em dashes. They pin the properties rather than the implementation: that a miner receives this validator's challenge verbatim, that one dead miner does not stop the others, and that an empty answer is a failure rather than an empty pass.