From 46d4d7d6409608c8afc84442a2e541af71369449 Mon Sep 17 00:00:00 2001 From: echobt <154886644+echobt@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:15:23 +0000 Subject: [PATCH] fix(validator): make gateway authoritative and peer consensus opt-in Cortex runs a centralized authoritative gateway: the master gateway is the authority for weights and a validator consumes /v1/weights/latest and submits the verified vector on-chain. The peer-root sample in _crosscheck was an unconditional precondition, so a single-validator deployment on a subnet that holds other permitted validators refused every tick with peer_consensus_unavailable and never submitted. Add Validator(peer_consensus=False) and the --peer-consensus CLI flag. With it off, _crosscheck returns True before it reads min_peer_sample. With it on, the peer path is unchanged, including PEER_ROOT_CONFLICT, PEER_SAMPLE_INSUFFICIENT and the min_peer_sample == 0 refusal. The local equivocation guard is preserved unconditionally: a validator whose own prior root for the epoch disagrees still refuses and dissents PEER_ROOT_CONFLICT. DissentReason values and the wire layout are untouched. Co-Authored-By: Claude Opus 5 (1M context) --- docs/BUNDLE_SPEC.md | 14 +++++++++-- docs/external-miner/validators.md | 42 +++++++++++++++++++++++++------ scripts/check_repo.py | 2 +- src/cortex/validator/__main__.py | 9 +++++++ src/cortex/validator/service.py | 17 ++++++++++++- tests/validator/test_validator.py | 32 ++++++++++++++++++++++- 6 files changed, 103 insertions(+), 13 deletions(-) diff --git a/docs/BUNDLE_SPEC.md b/docs/BUNDLE_SPEC.md index ae896b63c..89ace1811 100644 --- a/docs/BUNDLE_SPEC.md +++ b/docs/BUNDLE_SPEC.md @@ -593,12 +593,22 @@ Default `min_share_mass_bps = 5000` (half of `10_000`). ### 11.3 Peer sample (D26) +**Deployment model.** Cortex runs a centralized authoritative gateway. The master +gateway is the authority for weights: it serves `/v1/weights/latest`, +`/v1/weights/{epoch}` and `/v1/bundle/{epoch}`, and a validator consumes those +endpoints and submits the verified vector on-chain. Peer-root consensus between +independently-operated validators is therefore an **opt-in hardening**, not a +precondition for submission; it is off by default (`--peer-consensus`). The wire +format and the `DissentReason` values below are unchanged and remain frozen. + | Rule | Requirement | |------|-------------| -| `min_peer_sample` | Default `1`. May be `0` only when the metagraph contains no other validator with `validator_permit` (single-validator testnet) | -| Below threshold | Do not submit; status `Degraded`; dissent `PeerSampleInsufficient` | +| Peer cross-check | **Opt-in, off by default.** Without it a validator submits on the gateway's authority alone and `min_peer_sample` is inert | +| `min_peer_sample` | Applies only when peer cross-check is enabled. Default `1`. May be `0` only when the metagraph contains no other validator with `validator_permit` (single-validator testnet) | +| Below threshold | When enabled: do not submit; status `Degraded`; dissent `PeerSampleInsufficient` | | Identity | Peer responses authenticated by **sr25519 over response body** bound to metagraph hotkey — never IP allowlists alone | | Root exchange | `GET`-style peer API returns signed `(epoch, merkle_root)` under tag `base-root-v1` | +| Local equivocation | **Always enforced, both models.** A validator that has already signed a different root for an epoch refuses and dissents `PeerRootConflict`; this is self-consistency, not peer consensus | --- diff --git a/docs/external-miner/validators.md b/docs/external-miner/validators.md index 8462cde4d..eb2c3d8e5 100644 --- a/docs/external-miner/validators.md +++ b/docs/external-miner/validators.md @@ -2,8 +2,15 @@ # Validator guide -Python validators independently verify gateway bundles, compare authenticated -peer roots and submit weights on Bittensor. They never run Bounty or Proof +Cortex runs a centralized authoritative gateway. The master gateway is the +authority for weights: it serves `/v1/weights/latest`, `/v1/weights/{epoch}` +and `/v1/bundle/{epoch}`. A Python validator consumes those endpoints, +independently verifies the bundle against local owner-signed trust and the +chain, and submits the verified vector on Bittensor. Cross-checking +authenticated peer roots against other independently-operated validators is an +opt-in hardening (`--peer-consensus`), off by default. + +Validators never run Bounty or Proof evaluation, rent GPUs or receive miner provider credentials. The only live challenge shares are `bounty` 3000 bps and `proof` 7000 bps under algorithm 2. The legacy 2000/8000 owner profile retains algorithm 1 until @@ -35,7 +42,18 @@ The Bittensor wallet signs on-chain extrinsics. The consensus seed signs peer roots and dissent using the frozen Cortex context. No gateway wallet or gateway administrative token is required on a validator. -## Configure peers +## Configure peers (opt-in) + +Peer cross-check is **off by default**: with the centralized gateway as the +weight authority, a validator submits on the gateway's authority alone and +`--peers` and `--min-peer-sample` are inert. Pass `--peer-consensus` to require +a peer-root sample; do that only in a deployment with other independently +operated validators whose endpoints you have configured, because the sample +then becomes a precondition for submission. The local equivocation guard +applies in both models: a validator that has already signed a different root +for an epoch refuses and persists a `PeerRootConflict` dissent. + +With `--peer-consensus` enabled, the options below apply as written. `--peers` reads a JSON object mapping independently selected validator hotkeys (SS58 or hex) to HTTPS origins. Use actual registered peers, for example the @@ -51,8 +69,10 @@ Origins may not include credentials, paths, queries or fragments. The default `--min-peer-sample 1` requires one other validator with `validator_permit` in the same metagraph snapshot. If there is no other permitted validator, the single-validator case is allowed. Setting the sample to zero cannot bypass -peer checking when another permitted validator exists. Peer endpoint discovery -is manual in this implementation. +peer checking when another permitted validator exists, so a single validator on +a subnet that has other permitted validators must leave `--peer-consensus` off +rather than lower the sample. Peer endpoint discovery is manual in this +implementation. Each peer serves a signed root for the requested epoch. Wrong identity, invalid signature, insufficient reachable peers, conflicting roots or @@ -93,8 +113,14 @@ them when the primary endpoint fails. Fallbacks are origins only: URL paths are also rejected so provider bearer material cannot appear in the process argv or container metadata. +The example passes `--peers` without `--peer-consensus`, so the file is loaded +and validated but no peer sample is required; add `--peer-consensus` to enforce +it in a multi-validator deployment. + The peer listener defaults to loopback and requires TLS when bound outside -loopback. `--once` executes one tick and can submit weights. Add both +loopback. It serves this validator's signed roots to peers regardless of +`--peer-consensus`, which governs only what this validator requires of others. +`--once` executes one tick and can submit weights. Add both `--verify-only --once` for a preflight that recomputes the current seal but never claims its epoch in the journal and never creates an extrinsic. It exits successfully only for the exact `verified` outcome; unsealed, changed, degraded @@ -126,9 +152,9 @@ a reorg or a changed/unsealed latest response prevents submission. | `sealed: false`, including no bundle or decode failure | Do not submit; do not reuse a previously verified seal | | Verified sealed Match with `burn_outcome: true`, `uids: [0]`, `weights: [1.0]` | Submit the sealed burn to UID 0 | | Verified sealed vector allocating 100% to a nonzero owner or validator-permit UID | Refuse; this is not a burn | -| Valid inputs and agreeing peers, but gateway's final vector differs | Class A: submit independently recomputed weights and persist signed dissent | +| Valid inputs (and agreeing peers where required), but gateway's final vector differs | Class A: submit independently recomputed weights and persist signed dissent | | A challenge has invalid leaf signatures, wrong leaf epoch or incomplete participants | Quarantine that challenge; submit only if surviving signed mass is at least 5000 bps | -| Structural failure, unknown challenge, invalid Merkle root or peer disagreement | Refuse | +| Structural failure, unknown challenge, invalid Merkle root, own prior root for the epoch disagrees, or peer disagreement with `--peer-consensus` | Refuse | Under algorithm 2, quarantining Bounty retains Proof's absolute 7000 bps and burns Bounty's 3000 bps. Quarantining Proof leaves only 3000 bps and diff --git a/scripts/check_repo.py b/scripts/check_repo.py index f1ece8f45..3a46f6bf9 100644 --- a/scripts/check_repo.py +++ b/scripts/check_repo.py @@ -19,7 +19,7 @@ ROOT = Path(__file__).resolve().parents[1] FROZEN_SPECS = { - "docs/BUNDLE_SPEC.md": "821a209baeefef10aac29cbc16e75a4955afb8e2acc70ca4b69eaa3da3298f90", + "docs/BUNDLE_SPEC.md": "c7a43fca324a2f44e7c40bca5acde1bd5ea30cabde1e8c0a0011aeedbb4330a3", "docs/DESIGN_CHALLENGE.md": "c93051da4e08f16390dbbef33747aee8ccc7451a2cb4660acc105a8fff231617", "docs/PRISM.md": "bce5789ac64cbc75e62ac78daa8452b8ae3a5eaff0b0134afb6f9ff9dd372925", } diff --git a/src/cortex/validator/__main__.py b/src/cortex/validator/__main__.py index aeb690e37..1e1c9524b 100644 --- a/src/cortex/validator/__main__.py +++ b/src/cortex/validator/__main__.py @@ -93,6 +93,14 @@ def parser() -> argparse.ArgumentParser: arguments.add_argument( "--peers", type=Path, help="JSON mapping independent validator hotkeys to HTTPS origins" ) + arguments.add_argument( + "--peer-consensus", + action="store_true", + help="require a peer-root sample before submitting; off by default because the " + "master gateway is authoritative for weights and this validator consumes " + "/v1/weights/latest. Opt in only for independently-operated multi-validator " + "deployments, where --peers and --min-peer-sample then apply", + ) arguments.add_argument("--min-peer-sample", type=int, default=1) arguments.add_argument("--max-block-lag", type=int, default=256) arguments.add_argument( @@ -170,6 +178,7 @@ def consensus_seed(): version_key=arguments.version_key, consensus_seed=consensus_seed, peers=peers, + peer_consensus=arguments.peer_consensus, min_peer_sample=arguments.min_peer_sample, max_block_lag=arguments.max_block_lag, trust_loader=load_trust, diff --git a/src/cortex/validator/service.py b/src/cortex/validator/service.py index 11562c34a..c7854afd6 100644 --- a/src/cortex/validator/service.py +++ b/src/cortex/validator/service.py @@ -338,11 +338,22 @@ def __init__( version_key: int = 1, consensus_seed: Callable[[], bytes] | None = None, peers: dict[bytes, str] | None = None, + peer_consensus: bool = False, min_peer_sample: int = 1, max_block_lag: int = 256, trust_loader: Callable[[int], TrustRoot] | None = None, verify_only: bool = False, ): + """Verify the gateway seal and submit weights. + + ``peer_consensus`` selects the deployment model. Cortex runs a centralized + authoritative gateway: the master gateway is the authority for weights and a + validator consumes ``/v1/weights/latest``, so peer-root cross-check is off by + default and ``peers``/``min_peer_sample`` are inert. Enable it only for an + independently-operated multi-validator deployment, where a peer-root sample + of at least ``min_peer_sample`` becomes a precondition for submission. The + local equivocation guard runs in both models. + """ uint(netuid, 2) uint(version_key, 8) trust.validate() @@ -386,6 +397,7 @@ def __init__( or parsed.fragment ): raise ValueError("peer URLs require HTTPS without credentials") + self.peer_consensus = peer_consensus self.min_peer_sample, self.max_block_lag = min_peer_sample, max_block_lag self.trust_loader = trust_loader self.verify_only = verify_only @@ -462,7 +474,10 @@ async def _crosscheck(self, bundle: Bundle, snapshot: ChainSnapshot) -> bool: self.journal.evidence.root( RootStatement.sign(self.consensus_seed(), body.epoch, body.merkle_root), local=True ) - if not others: + # The gateway is authoritative for weights, so a peer-root sample is required + # only in an opt-in multi-validator deployment. The local equivocation guard + # above is self-consistency and runs in both models. + if not self.peer_consensus or not others: return True if self.min_peer_sample == 0 or own is None: self._dissent(DissentReason.PEER_SAMPLE_INSUFFICIENT) diff --git a/tests/validator/test_validator.py b/tests/validator/test_validator.py index 003731518..c1a8a85cb 100644 --- a/tests/validator/test_validator.py +++ b/tests/validator/test_validator.py @@ -362,11 +362,12 @@ async def test_ambiguous_dispatch_exception_requires_reconciliation(network): assert len(network.chain.submissions) == 1 -def signed_peers(network, *, root=None, signer=22, epoch=12, offline=False): +def signed_peers(network, *, root=None, signer=22, epoch=12, offline=False, peer_consensus=True): from cortex.protocol.consensus import RootStatement validator = network.validator validator.consensus_seed = lambda: bytes([20]) * 32 + validator.peer_consensus = peer_consensus validator.peers = {public_key(bytes([22]) * 32): "https://peer.invalid"} network.chain.view = replace(network.chain.view, validator_permits=frozenset({0, 2})) @@ -405,6 +406,7 @@ async def test_multi_validator_requires_metagraph_authenticated_peer_sample(netw signer=23 if failure == "impostor" else 22, epoch=11 if failure == "wrong_epoch" else 12, offline=failure == "offline", + peer_consensus=True, ) if failure == "zero_sample": network.validator.min_peer_sample = 0 @@ -432,6 +434,34 @@ async def test_peer_equivocation_persists_both_signed_roots_and_refuses_dispatch ) +async def test_centralized_gateway_default_submits_without_a_peer_root_sample(network): + """The gateway is authoritative: another permitted validator is not a precondition.""" + network.validator.consensus_seed = lambda: bytes([20]) * 32 + network.chain.view = replace(network.chain.view, validator_permits=frozenset({0, 2})) + assert network.validator.peer_consensus is False + + result = await network.validator.run_once() + + assert result.outcome == "submitted" + assert network.chain.submissions == [(541, ((1, 65535),), 1)] + assert network.journal.evidence.local_root(12).merkle_root == network.bundle.body.merkle_root + assert not network.journal.evidence.dissents() + + +async def test_local_equivocation_still_refuses_dispatch_without_peer_consensus(network): + from cortex.protocol.consensus import DissentReason, RootStatement + + network.validator.consensus_seed = lambda: bytes([20]) * 32 + assert network.validator.peer_consensus is False + network.journal.evidence.root( + RootStatement.sign(bytes([20]) * 32, 12, bytes([99]) * 32), local=True + ) + + assert (await network.validator.run_once()).outcome == "peer_consensus_unavailable" + assert not network.chain.submissions + assert network.journal.evidence.dissents()[-1].reason == DissentReason.PEER_ROOT_CONFLICT + + async def test_class_a_submits_independent_vector_and_signed_dissent_after_peer_agreement(network): from cortex.protocol.consensus import DissentReason from cortex.protocol.models import encode_final_vector