diff --git a/src/cortex/master.py b/src/cortex/master.py index aabd4ae10..194e3602f 100644 --- a/src/cortex/master.py +++ b/src/cortex/master.py @@ -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: diff --git a/src/cortex/protocol/aggregate.py b/src/cortex/protocol/aggregate.py index fd5138dbd..77b8c1205 100644 --- a/src/cortex/protocol/aggregate.py +++ b/src/cortex/protocol/aggregate.py @@ -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}) @@ -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 diff --git a/src/cortex/validator/__main__.py b/src/cortex/validator/__main__.py index 1e1c9524b..7c258bdcc 100644 --- a/src/cortex/validator/__main__.py +++ b/src/cortex/validator/__main__.py @@ -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 @@ -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") 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) @@ -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" @@ -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()) @@ -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 — //hotkeys/ — 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 ( diff --git a/src/cortex/validator/keystore.py b/src/cortex/validator/keystore.py new file mode 100644 index 000000000..1ba3e547a --- /dev/null +++ b/src/cortex/validator/keystore.py @@ -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 diff --git a/tests/protocol/test_aggregate.py b/tests/protocol/test_aggregate.py index cef1a34d1..a420aad58 100644 --- a/tests/protocol/test_aggregate.py +++ b/tests/protocol/test_aggregate.py @@ -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) diff --git a/tests/validator/test_hotkey_private_key.py b/tests/validator/test_hotkey_private_key.py new file mode 100644 index 000000000..bac7f9bad --- /dev/null +++ b/tests/validator/test_hotkey_private_key.py @@ -0,0 +1,135 @@ +"""Loading a validator hotkey from a private key file. + +The case this exists for: an operator key exported from a Polkadot-style +keystore, which is a 64-byte sr25519 mini-secret and has no mnemonic. The +wallet library cannot load one, so the loader here builds the object the submit +path needs and refuses anything it cannot vouch for. +""" + +import json +import os +from pathlib import Path + +import pytest +import sr25519 + +from cortex.protocol import ProtocolError +from cortex.protocol.crypto import encode_hotkey +from cortex.validator.keystore import carries_private_key, load_private_key_wallet + +# A fresh key for the fixture. Not a secret: it is generated in the test and +# never leaves it. +_SEED = bytes(range(32)) +_PUBLIC, _EXPANDED = sr25519.pair_from_seed(_SEED) +# The 64-byte expanded secret is what a Polkadot keystore exports: scalar ‖ nonce. +# It is *not* seed ‖ secret, which would be 96 bytes. +_SS58 = encode_hotkey(_PUBLIC) + + +def write(tmp_path: Path, body: dict, *, mode: int = 0o600) -> Path: + path = tmp_path / "hotkey" + path.write_text(json.dumps(body)) + path.chmod(mode) + return path + + +def test_a_64_byte_private_key_loads_and_signs_as_its_own_account(tmp_path): + path = write( + tmp_path, {"privateKey": "0x" + _EXPANDED.hex(), "cryptoType": 1, "ss58Address": _SS58} + ) + wallet = load_private_key_wallet(path) + + assert wallet.hotkey.public_key == _PUBLIC + assert wallet.hotkey.ss58_address == _SS58 + + # The signature must be one a Bittensor verifier accepts: same primitive, + # same public key. + payload = b"commit-mechanism-weights" + signature = wallet.hotkey.sign(payload) + assert len(signature) == 64 + assert sr25519.verify(signature, payload, _PUBLIC) + assert wallet.hotkey.verify(payload, signature) + + +def test_a_32_byte_seed_loads_too(tmp_path): + path = write(tmp_path, {"privateKey": "0x" + _SEED.hex(), "cryptoType": 1}) + wallet = load_private_key_wallet(path) + assert wallet.hotkey.public_key == _PUBLIC + + +def test_the_hex_prefix_is_optional(tmp_path): + path = write(tmp_path, {"privateKey": _EXPANDED.hex(), "cryptoType": 1}) + assert load_private_key_wallet(path).hotkey.public_key == _PUBLIC + + +def test_a_key_that_does_not_match_its_declared_address_is_refused(tmp_path): + # The check worth having: this file signs as a different account than the + # operator named, and the chain would reject it at submission instead. + other = encode_hotkey(sr25519.pair_from_seed(bytes(range(1, 33)))[0]) + path = write(tmp_path, {"privateKey": "0x" + _EXPANDED.hex(), "ss58Address": other}) + with pytest.raises(ProtocolError, match="does not derive"): + load_private_key_wallet(path) + + +def test_a_public_or_symlinked_file_is_refused(tmp_path): + path = write(tmp_path, {"privateKey": "0x" + _EXPANDED.hex()}, mode=0o644) + with pytest.raises(ProtocolError, match="private regular file"): + load_private_key_wallet(path) + + secret = write(tmp_path, {"privateKey": "0x" + _EXPANDED.hex()}) + link = tmp_path / "link" + os.symlink(secret, link) + with pytest.raises(ProtocolError, match="unreadable"): + load_private_key_wallet(link) + + +@pytest.mark.parametrize( + "body", + [ + {"privateKey": "0x00"}, # not a key length + {"privateKey": "zz"}, # not hex + {"privateKey": 5}, # not a string + {}, # no key at all + {"privateKey": "0x" + _EXPANDED.hex(), "cryptoType": 2}, # not sr25519 + ], +) +def test_a_malformed_file_is_refused(tmp_path, body): + path = write(tmp_path, body) + with pytest.raises(ProtocolError): + load_private_key_wallet(path) + + +def test_a_file_that_is_not_json_is_refused(tmp_path): + path = tmp_path / "hotkey" + path.write_text("not json") + path.chmod(0o600) + with pytest.raises(ProtocolError, match="not JSON"): + load_private_key_wallet(path) + + +def test_an_unknown_attribute_fails_loudly(tmp_path): + # A wallet with a hotkey and nothing else looks enough like a Wallet for the + # submit path, but a future call site asking for a coldkey must fail here + # rather than AttributeError somewhere inside the SDK. + path = write(tmp_path, {"privateKey": "0x" + _EXPANDED.hex()}) + wallet = load_private_key_wallet(path) + with pytest.raises(ProtocolError, match="no 'coldkey'"): + _ = wallet.coldkey + + +def test_detection_picks_a_keystore_and_leaves_a_mnemonic_wallet_alone(tmp_path): + # The command line does not change: the file decides. + keystore = write(tmp_path, {"privateKey": "0x" + _EXPANDED.hex(), "cryptoType": 1}) + assert carries_private_key(keystore) is True + + wallet_like = write( + tmp_path, {"secretPhrase": "abandon abandon about", "privateKey": "0x" + _EXPANDED.hex()} + ) + assert carries_private_key(wallet_like) is False + + assert carries_private_key(tmp_path / "absent") is False + + +def test_detection_refuses_a_keystore_with_no_usable_key(tmp_path): + path = write(tmp_path, {"privateKey": "0x00"}) + assert carries_private_key(path) is False