Skip to content
Open
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
5 changes: 4 additions & 1 deletion src/cortex/master.py
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,10 @@ async def _loop(self, operation, seconds: float) -> None:
try:
await operation()
except Exception as error:
logging.warning("master background operation failed (%s)", type(error).__name__)
# The type alone is not diagnosable: an epoch loop that fails
# every tick emits nothing, and the only symptom upstream is a
# burn. Keep the message and the traceback.
logging.warning("master background operation failed", exc_info=error)
try:
await asyncio.wait_for(self._stop.wait(), timeout=seconds)
except TimeoutError:
Expand Down
13 changes: 12 additions & 1 deletion src/cortex/protocol/aggregate.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,17 @@ def aggregate_challenge_weights(
kept[key] = score
miner_total = compensated_sum(by_uid.values())
if miner_total <= 1e-12:
# Nothing was claimed. The mass that burns is the allocation no miner
# took, which the challenge document states: the bounty share burns only
# when there is no payable report, and the proof share burns when no
# submission is credited. Burning less than the full proof share here
# would mint emission nobody earned.
burn_mass = compensated_sum(fractions.values())
if burn_mass <= 1e-12:
raise ProtocolError("no challenge carries an emission share")
# The chain still requires a minimum number of positive weights, so the
# burn is spread over that many uids. It stays a burn either way, but it
# is the declared allocations that decide how much burns.
if max_weight_limit <= 0:
raise ProtocolError(f"max_weight_limit={max_weight_limit} admits no positive weight")
candidates = [0] + sorted(set(hotkey_to_uid.values()) - {0})
Expand All @@ -95,7 +106,7 @@ def aggregate_challenge_weights(
f"max_weight_limit={max_weight_limit}) but only {len(candidates)} "
"usable uid(s) available"
)
by_uid = dict.fromkeys(candidates[:needed], 1.0 / needed)
by_uid = dict.fromkeys(candidates[:needed], burn_mass / needed)
kept = {}
else:
burn = 1.0 - miner_total
Expand Down
50 changes: 40 additions & 10 deletions src/cortex/validator/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@

from .chain import BittensorChain, close_subtensor
from .evidence import peer_app
from .keystore import PrivateKeyWallet, load_private_key_wallet
from .keystore import carries_private_key as _carries_private_key
from .service import SubmissionJournal, TickResult, Validator


Expand Down Expand Up @@ -77,8 +79,8 @@ def parser() -> argparse.ArgumentParser:
arguments.add_argument("--minimum-measurements-version", type=int, required=True)
arguments.add_argument("--network", type=primary_endpoint, default="finney")
arguments.add_argument("--fallback-endpoints", type=fallback_endpoints, default=[])
arguments.add_argument("--wallet-name", required=True)
arguments.add_argument("--wallet-hotkey", required=True)
arguments.add_argument("--wallet-name")
arguments.add_argument("--wallet-hotkey")
Comment on lines +82 to +83

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Reject missing wallet values

--wallet-name and --wallet-hotkey now parse as None, but startup unconditionally uses them as path components. Invoking the validator without them therefore reaches an uncaught TypeError instead of reporting the missing options clearly. Make the options required or validate them immediately after parsing. This is non-blocking, but it makes a common configuration error harder to diagnose.

Suggested change
arguments.add_argument("--wallet-name")
arguments.add_argument("--wallet-hotkey")
arguments.add_argument("--wallet-name", required=True)
arguments.add_argument("--wallet-hotkey", required=True)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Artifacts

Evidence from the check

  • The authored script invokes the validator with every unrelated required option but omits both wallet values, then executes the real parser and main path construction; it demonstrates the focused test setup.

Command output from the check

  • Captured command output shows the direct invocation's Python-version blocker and the executed real main-path reproduction ending in the uncaught Path/None TypeError; omission is not rejected by argparse.

View artifacts

T-Rex Ran code and verified through T-Rex

arguments.add_argument("--wallet-path", default="~/.bittensor/wallets")
arguments.add_argument("--state-db", type=Path, required=True)
arguments.add_argument("--poll-seconds", type=float, default=30)
Expand All @@ -87,8 +89,10 @@ def parser() -> argparse.ArgumentParser:
arguments.add_argument(
"--consensus-seed-file",
type=Path,
required=True,
help="private sr25519 seed for the same validator hotkey",
help="private sr25519 seed for the same validator hotkey. Required only "
"with --peer-consensus: the seed signs cross-validator root statements "
"and dissents, and the default deployment submits from the gateway "
"without either.",
)
arguments.add_argument(
"--peers", type=Path, help="JSON mapping independent validator hotkeys to HTTPS origins"
Expand Down Expand Up @@ -133,13 +137,22 @@ def load_trust(epoch):
minimum_measurements_version=arguments.minimum_measurements_version,
)

# The seed signs cross-validator root statements and dissents, which only
# exist in a peer-consensus deployment (_crosscheck returns immediately
# otherwise, and _dissent does nothing without a seed). Checking it
# unconditionally refused a plain gateway-backed validator whose hotkey is
# an exported keystore key rather than a mnemonic: such a key carries an
# expanded secret, not the 32-byte seed this check wants, so the check
# failed for a deployment that would never have read the seed at all.
def consensus_seed():
if arguments.consensus_seed_file is None:
raise ProtocolError("--peer-consensus requires --consensus-seed-file")
seed = read_seed(arguments.consensus_seed_file)
if public_key(seed) != wallet.hotkey.public_key:
raise ProtocolError("consensus seed must match validator wallet hotkey")
return seed

consensus_seed()
consensus_seed() if arguments.peer_consensus else None
peers = {}
if arguments.peers:
values = json.loads(arguments.peers.read_text())
Expand Down Expand Up @@ -233,11 +246,28 @@ def main(argv: list[str] | None = None) -> None:
subtensor = Subtensor(
network=arguments.network, fallback_endpoints=arguments.fallback_endpoints
)
wallet = Wallet(
name=arguments.wallet_name,
hotkey=arguments.wallet_hotkey,
path=str(Path(arguments.wallet_path).expanduser()),
)
# A hotkey exported from a Polkadot-style keystore has no mnemonic, so the
# wallet library cannot load it (see .hotkey). That file is an alternative
# source, and exactly one source must be given: a wallet whose hotkey is not
# the key the operator means is worse than a refusal at startup.
# A hotkey exported from a Polkadot-style keystore lives at the same path a
# mnemonic wallet does — <path>/<name>/hotkeys/<hotkey> — so the file
# decides how it is read, and the command line stays what the deploy gate
# audits. A mnemonic wallet keeps working exactly as before.
wallet_path = Path(arguments.wallet_path).expanduser()
hotkey_file = wallet_path / arguments.wallet_name / "hotkeys" / arguments.wallet_hotkey
wallet: Wallet | PrivateKeyWallet
if hotkey_file.is_file() and _carries_private_key(hotkey_file):
try:
wallet = load_private_key_wallet(hotkey_file)
except ProtocolError as error:
raise SystemExit(f"validator wallet refused: {error}") from None
else:
wallet = Wallet(
name=arguments.wallet_name,
hotkey=arguments.wallet_hotkey,
path=str(wallet_path),
)
try:
result = asyncio.run(run(arguments, subtensor, wallet))
if (
Expand Down
199 changes: 199 additions & 0 deletions src/cortex/validator/keystore.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
"""A hotkey loaded from a private key file, in the shape the chain expects.

# Why this exists

`bittensor_wallet.Wallet` derives a hotkey from a BIP-39 mnemonic in
`secretPhrase`; it reads `privateKey`, `publicKey` and `ss58Address` but
overwrites them with whatever the phrase produces. That is fine for a key the
wallet itself generated, and it is the only way to reload it.

It does not work for an operator key exported from a Polkadot-style keystore.
Such a key is a 64-byte sr25519 mini-secret — a seed and its expanded secret —
and no mnemonic produces it. Measured against the wallet library: a file
carrying only `privateKey`, with or without `secretPhrase`, raises
`KeyFileError: Invalid phrase`, and a file carrying a valid phrase alongside a
foreign `privateKey` silently reloads the *phrase's* key instead.

So this module builds the minimal object `sign_and_send_extrinsic` needs:

.hotkey.public_key 32 bytes, the sr25519 public key
.hotkey.ss58_address the SS58 form, for the registration lookup
.hotkey.sign(data) an sr25519 signature over the payload
.hotkey.sign_with... the wallet's own signing helpers, if it asks

`sign` goes through `sr25519.sign`, the same primitive `sign_substrate` uses,
so a signature made here is the one a Bittensor verifier accepts.

# Scope

This is a *loader*, not a key generator. It refuses anything that is not a
regular, owner-only file, and it refuses a key whose public half does not match
the declared SS58 address — a mismatch that would otherwise surface as a chain
rejection long after the mistake.
"""

import json
import os
import stat
from dataclasses import dataclass
from pathlib import Path
from typing import Any

import sr25519

from cortex.protocol import ProtocolError
from cortex.protocol.crypto import encode_hotkey

# Bittensor signs and submits under SR25519. Any other scheme here would produce
# a signature the chain rejects, so a file declaring another one is refused
# rather than converted.
_SR25519_CRYPTO_TYPE = 1


def _read_private_file(path: Path) -> bytes:
"""Read a key file that only its owner can open."""
try:
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
except OSError:
raise ProtocolError("keystore file is unreadable") from None
with os.fdopen(descriptor, "rb") as stream:
metadata = os.fstat(stream.fileno())
if not stat.S_ISREG(metadata.st_mode) or metadata.st_mode & 0o077:
raise ProtocolError("keystore file must be a private regular file")
return stream.read(1 << 20)


def _private_bytes(value: object) -> bytes:
"""A hex private key, with or without the 0x prefix."""
if not isinstance(value, str):
raise ProtocolError("hotkey privateKey must be a hex string")
text = value.strip().removeprefix("0x").removeprefix("0X")
try:
raw = bytes.fromhex(text)
except ValueError:
raise ProtocolError("hotkey privateKey is not hex") from None
# Two shapes are accepted, and they are different things:
#
# 32 bytes a seed, from which the pair is derived
# 64 bytes an *expanded* secret — scalar ‖ nonce, the form a Polkadot
# keystore exports. It is NOT a seed ‖ secret mini-secret (that
# is 96 bytes), so it must be read as a secret, not as a seed.
#
# Anything else is refused rather than guessed at.
if len(raw) not in (32, 64):
raise ProtocolError("hotkey privateKey must be 32 or 64 bytes")
return raw


def _public_from(private: bytes) -> bytes:
if len(private) == 64:
return sr25519.public_from_secret_key(private)
return sr25519.pair_from_seed(private)[0]


@dataclass(frozen=True)
class Hotkey:
"""The three things the submit path asks of a wallet's hotkey."""

_public: bytes
_private: bytes

@property
def public_key(self) -> bytes:
return self._public

@property
def ss58_address(self) -> str:
return encode_hotkey(self._public)

def sign(self, data: bytes) -> bytes:
if len(self._private) == 64:
return sr25519.sign((self._public, self._private), data)
return sr25519.sign(sr25519.pair_from_seed(self._private), data)

def verify(self, data: bytes, signature: bytes) -> bool:
try:
return bool(sr25519.verify(signature, data, self._public))
except ValueError:
return False


@dataclass(frozen=True)
class PrivateKeyWallet:
"""The subset of a Bittensor `Wallet` this validator uses.

Built from a keystore file that carries `privateKey`. Construct it with
`load_private_key_wallet`, which enforces the file checks.
"""

hotkey: Hotkey
name: str

def __getattr__(self, item: str) -> Any:
raise ProtocolError(
f"private-key wallet has no {item!r}: this loader supports signing only"
)


def load_private_key_wallet(path: Path, *, name: str | None = None) -> PrivateKeyWallet:
"""Load a hotkey from a keystore file carrying `privateKey`.

Raises `ProtocolError` for a file that is public, a symlink, malformed, or
whose key does not match the `ss58Address` it declares. The last check is
the one worth having: a mismatched pair signs as a different account than
the operator named, and the chain would reject it at submission rather than
here.
"""
raw = _read_private_file(path)
try:
document = json.loads(raw)
except ValueError:
raise ProtocolError("keystore file is not JSON") from None
if not isinstance(document, dict):
raise ProtocolError("keystore file is not a JSON object")

crypto_type = document.get("cryptoType", _SR25519_CRYPTO_TYPE)
if crypto_type != _SR25519_CRYPTO_TYPE:
raise ProtocolError("hotkey cryptoType must be 1 (sr25519)")

private = _private_bytes(document.get("privateKey"))
public = _public_from(private)

declared = document.get("ss58Address")
if isinstance(declared, str) and declared:
if encode_hotkey(public) != declared:
raise ProtocolError("hotkey privateKey does not derive the ss58Address it declares")

return PrivateKeyWallet(
hotkey=Hotkey(_public=public, _private=private), name=name or "private-key"
)


def carries_private_key(path: Path) -> bool:
"""Whether a hotkey file is one this loader should read.

A mnemonic wallet writes `secretPhrase` and leaves `privateKey` absent or
stale; a Polkadot-style keystore carries a usable `privateKey` and no
mnemonic. Deciding on the file keeps the command line unchanged, so the
audited deploy surface stays what the gate checks.

Unreadable or malformed files answer False: the caller falls back to the
wallet library, which reports the problem in its own terms.
"""
try:
raw = _read_private_file(path)
document = json.loads(raw)
except (ProtocolError, ValueError):
return False
if not isinstance(document, dict):
return False
# A phrase means the wallet library owns this file, even if a privateKey is
# also present: it derives from the phrase and ignores the key.
phrase = document.get("secretPhrase")
if isinstance(phrase, str) and phrase.strip():
return False
try:
_private_bytes(document.get("privateKey"))
except ProtocolError:
return False
return True
32 changes: 32 additions & 0 deletions tests/protocol/test_aggregate.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,38 @@ def test_v2_burns_uid0_and_unmapped_authors_without_transferring_their_mass():
assert result.weights == pytest.approx((0.9, 0.1))


def test_v2_with_no_miner_burns_only_the_declared_proof_share():
"""Zero miners: bounty must not burn as much as proof.

The operator's rule is that the burn at the end is the proof share, because
the bounty allocation exists to pay reports. Padding the vector across
arbitrary uids at equal weight said nothing about that: it burned the same
amount whichever challenge was empty.
"""
result = aggregate_leaves(
(),
((b"bounty", 3000), (b"proof", 7000)),
(),
algorithm_version=2,
)
# The whole declared allocation burns, and proof is the larger part of it.
assert sum(result.weights) == pytest.approx(0.7)
assert result.hotkey_weights == {}


def test_v2_with_no_miner_keeps_bounty_below_proof():
"""The bounty side is the smaller burn, whichever uid carries it."""
result = aggregate_leaves(
(),
((b"bounty", 3000), (b"proof", 7000)),
((bytes([9]) * 32, 5),),
algorithm_version=2,
)
# 0.7 spread over the uids the chain needs, never the full 1.0 the old
# equal-padding produced.
assert sum(result.weights) == pytest.approx(0.7)


def test_v2_rejects_other_share_profiles():
with pytest.raises(ProtocolError, match="shares"):
aggregate_leaves((), LIVE_SHARES, (), algorithm_version=2)
Expand Down
Loading
Loading