Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions docs/BUNDLE_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

---

Expand Down
42 changes: 34 additions & 8 deletions docs/external-miner/validators.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion scripts/check_repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
Expand Down
9 changes: 9 additions & 0 deletions src/cortex/validator/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
17 changes: 16 additions & 1 deletion src/cortex/validator/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
32 changes: 31 additions & 1 deletion tests/validator/test_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}))

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading