diff --git a/chain_layer/bittensor_chain.py b/chain_layer/bittensor_chain.py index 9e018a9..3bb20d9 100644 --- a/chain_layer/bittensor_chain.py +++ b/chain_layer/bittensor_chain.py @@ -267,6 +267,56 @@ def verify_handshake_onchain( ) return True, "handshake verified on-chain" + # ------------------------------------------------------------------ + # Ninja-style (SN66) one-commitment-per-hotkey submission anchoring. + # Content-addressed + readable, vs the opaque/race-prone handshake above. + # Additive: these do NOT change the legacy op1 path — the validator is + # switched over to verify_submission_commitment_onchain in a coordinated + # cutover (see feat/ninja-commitment rollout notes). + # ------------------------------------------------------------------ + def commit_submission(self, miner_hotkey: str, bundle_sha256: str) -> str: + """Miner side: anchor the CURRENT submission as a content-addressed + commitment in this hotkey's single on-chain slot. Overwriting is the + update mechanism — no nonce, no race. Returns the committed string. + """ + from .submission_commitment import build_commitment + + commitment = build_commitment(bundle_sha256, hotkey=miner_hotkey) + try: + self.subtensor.set_commitment( + wallet=self.wallet, + netuid=self.netuid, + data=commitment, + ) + print(f"[chain] submission committed on-chain: {commitment}") + except AttributeError: + raise RuntimeError( + "bittensor SDK has no set_commitment method (expected on bittensor>=10.0)." + ) + except Exception as e: + raise RuntimeError(f"on-chain submission commit failed: {type(e).__name__}: {e}") + return commitment + + def verify_submission_commitment_onchain( + self, miner_hotkey: str, bundle_sha256: str + ) -> tuple[bool, str]: + """Validator side: does this hotkey's on-chain commitment name THIS + bundle? Reads the single commitment slot (the miner's current submission) + and checks it is the readable, content-addressed pointer to bundle_sha256. + """ + from .submission_commitment import verify_commitment + + uid = self.get_uid(miner_hotkey) + if uid is None: + return False, "hotkey not registered on subnet" + try: + committed = self.subtensor.get_commitment(netuid=self.netuid, uid=uid) + except Exception as e: + return False, f"get_commitment failed: {type(e).__name__}: {e}" + if not committed: + return False, "no on-chain submission commitment for this hotkey" + return verify_commitment(str(committed).strip(), hotkey=miner_hotkey, bundle_sha256=bundle_sha256) + def is_hotkey_registered(self, hotkey: str) -> bool: """Check if a hotkey is registered on the subnet's metagraph.""" self.sync() diff --git a/chain_layer/submission_commitment.py b/chain_layer/submission_commitment.py new file mode 100644 index 0000000..24ecabb --- /dev/null +++ b/chain_layer/submission_commitment.py @@ -0,0 +1,109 @@ +"""Ninja-style (SN66) one-commitment-per-hotkey submission anchoring. + +Ralph's legacy handshake commits an OPAQUE hash to the chain +(``sha256("karpa:handshake:{hotkey}:{patch_hash}:{nonce}")``): the validator +cannot read which bundle it points to — it must *reconstruct* the hash from a +submission it already has, and the embedded nonce makes the commitment race-prone +("submit immediately after committing, or set_commitment overwrites it"). + +SN66 Ninja solves this with a READABLE, content-addressed commitment in the +single on-chain slot each hotkey has: + + private-submission:: + ("Only one accepted submission is eligible per miner hotkey registration.") + +This module ports that pattern to Ralph. The commitment is:: + + ralph-submission:: + +set on-chain via ``subtensor.set_commitment`` — itself a hotkey-signed extrinsic, +so the commitment is authenticated by the chain (no extra signature needed). Key +properties vs the legacy handshake: + + * **Readable / self-describing** — the validator reads the bundle hash straight + from the chain and knows exactly which bundle is this hotkey's current one, + without reconstructing anything or matching off-chain PR titles. + * **One-per-hotkey is the feature** — overwriting the single slot is how a + miner *updates* their submission; it is not a race. The commitment always + names the current bundle. + * **Content-addressed** — the validator verifies the bundle it holds hashes to + the committed value, binding the on-chain pointer to exact bytes. + +Scope: this is the op1 SUBMISSION-IDENTITY anchor. Attestation freshness (the +op2 nonce that flows into the TDX/NVIDIA quotes) is a separate concern and is +unchanged by this module. +""" +from __future__ import annotations + +import re + +PREFIX = "ralph-submission" +SIG_PREFIX = "ralph-submission-v1" + +# A full, self-describing commitment string. submission_id is a short human/label +# token (<=128 of [A-Za-z0-9_.-]); the trailing field is a lowercase sha256. +COMMITMENT_RE = re.compile(rf"^{re.escape(PREFIX)}:[A-Za-z0-9_.-]{{1,128}}:[0-9a-f]{{64}}$") +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") + + +def derive_submission_id(hotkey: str, content_sha256: str) -> str: + """Stable, content-addressed id ``-`` (Ninja-style). + + Binding the id to BOTH the hotkey and the content means it changes whenever + the bundle changes and cannot be made to masquerade as another hotkey's id. + """ + safe = re.sub(r"[^A-Za-z0-9_.-]", "-", hotkey)[:16] or "hotkey" + return f"{safe}-{content_sha256.lower()[:16]}" + + +def build_commitment(content_sha256: str, *, hotkey: str, submission_id: str | None = None) -> str: + """Build the on-chain commitment string a miner sets via set_commitment.""" + sha = content_sha256.strip().lower() + if not _SHA256_RE.match(sha): + raise ValueError(f"content_sha256 must be 64 lowercase hex chars, got {content_sha256!r}") + sid = submission_id or derive_submission_id(hotkey, sha) + commitment = f"{PREFIX}:{sid}:{sha}" + if not COMMITMENT_RE.match(commitment): + raise ValueError(f"built commitment is malformed: {commitment!r}") + return commitment + + +def parse_commitment(commitment: str) -> tuple[str, str]: + """Return ``(submission_id, content_sha256)`` or raise ValueError.""" + c = (commitment or "").strip() + if not COMMITMENT_RE.match(c): + raise ValueError(f"not a valid {PREFIX} commitment: {commitment!r}") + _, sid, sha = c.split(":", 2) + return sid, sha + + +def signature_payload(hotkey: str, submission_id: str, content_sha256: str) -> bytes: + """Optional hotkey-signature payload (parity with Ninja's tau-* payload). + + Not required when the commitment is set directly on-chain (set_commitment is + already hotkey-signed); useful if a submission is relayed off-chain. + """ + return f"{SIG_PREFIX}:{hotkey}:{submission_id}:{content_sha256.lower()}".encode() + + +def verify_commitment(commitment: str, *, hotkey: str, bundle_sha256: str) -> tuple[bool, str]: + """Validator-side: does the on-chain commitment name THIS bundle for THIS hotkey? + + Verifies: well-formed; the committed content hash equals the bundle the + validator holds; and the submission_id is the canonical derivation for + (hotkey, content) — so a commitment cannot point at one hotkey's id while + carrying another's content. Returns ``(ok, reason)``. + """ + try: + sid, sha = parse_commitment(commitment) + except ValueError as e: + return False, str(e) + if sha != bundle_sha256.strip().lower(): + return False, ( + f"commitment content hash {sha[:16]}… != submitted bundle " + f"{bundle_sha256.strip().lower()[:16]}…" + ) + expected_sid = derive_submission_id(hotkey, sha) + if sid != expected_sid: + return False, f"submission_id {sid!r} is not canonical {expected_sid!r} for this hotkey/content" + return True, "commitment verified (content-addressed, one-per-hotkey)" diff --git a/tests/test_inflight.py b/tests/test_inflight.py new file mode 100644 index 0000000..65c2ab7 --- /dev/null +++ b/tests/test_inflight.py @@ -0,0 +1,71 @@ +"""One-in-flight-per-hotkey: dynamic cooldown, idempotent, restart-safe.""" +from __future__ import annotations + +from validator.inflight import InFlightGuard + +HK = "5FCDTbBDka1WxcspxAjRMUeecp3vHnYiM1nenEsKGXysYqGE" +HK2 = "5H6mytgBYJbgTRNQv11gGfhFa1Dq91rd8TiSZQnKW7cmB1jb" +A = "a" * 64 +B = "b" * 64 + + +def test_claim_then_block_then_release(tmp_path): + g = InFlightGuard(tmp_path / "inflight.json") + ok, _ = g.claim(HK, A) + assert ok and g.in_flight(HK) == A + # a DIFFERENT bundle from the same hotkey is deferred while A is in flight + ok, reason = g.claim(HK, B) + assert not ok and "in flight" in reason + # once A is scored, the hotkey can submit again immediately (dynamic cooldown) + g.release(HK, A) + assert g.in_flight(HK) is None + ok, _ = g.claim(HK, B) + assert ok and g.in_flight(HK) == B + + +def test_claim_same_bundle_is_idempotent(tmp_path): + g = InFlightGuard(tmp_path / "inflight.json") + assert g.claim(HK, A)[0] + assert g.claim(HK, A)[0] # reprocess / restart must not be blocked + + +def test_other_hotkeys_are_independent(tmp_path): + g = InFlightGuard(tmp_path / "inflight.json") + assert g.claim(HK, A)[0] + assert g.claim(HK2, B)[0] # different hotkey, independent slot + assert g.in_flight(HK) == A and g.in_flight(HK2) == B + + +def test_release_with_mismatched_hash_is_noop(tmp_path): + g = InFlightGuard(tmp_path / "inflight.json") + g.claim(HK, A) + g.release(HK, B) # stale release must not clear the newer claim + assert g.in_flight(HK) == A + + +def test_state_persists_across_restart(tmp_path): + p = tmp_path / "inflight.json" + InFlightGuard(p).claim(HK, A) + # a fresh instance (validator restart) sees the in-flight claim + g2 = InFlightGuard(p) + assert g2.in_flight(HK) == A + ok, _ = g2.claim(HK, B) + assert not ok + + +def test_reconcile_clears_stale_claims(tmp_path): + g = InFlightGuard(tmp_path / "inflight.json") + g.claim(HK, A) # A got scored+archived but crash skipped release + g.claim(HK2, B) # B still pending + g.reconcile(valid_bundle_hashes={B}) # only B is still in the queue + assert g.in_flight(HK) is None # stale A claim cleared + assert g.in_flight(HK2) == B # live claim kept + assert g.claim(HK, A)[0] # HK can submit again + + +def test_corrupt_state_does_not_wedge(tmp_path): + p = tmp_path / "inflight.json" + p.write_text("{ not json") + g = InFlightGuard(p) # must load empty, not raise + assert g.in_flight(HK) is None + assert g.claim(HK, A)[0] diff --git a/tests/test_submission_commitment.py b/tests/test_submission_commitment.py new file mode 100644 index 0000000..a96e1a9 --- /dev/null +++ b/tests/test_submission_commitment.py @@ -0,0 +1,86 @@ +"""Ninja-style one-commitment-per-hotkey: content-addressed, readable, anti-spoof.""" +from __future__ import annotations + +import pytest + +from chain_layer.submission_commitment import ( + COMMITMENT_RE, + build_commitment, + derive_submission_id, + parse_commitment, + signature_payload, + verify_commitment, +) + +HK = "5FCDTbBDka1WxcspxAjRMUeecp3vHnYiM1nenEsKGXysYqGE" +SHA = "d93b5c601bf339ebee19528b3e457dcbded75e3ef15f448c0a9b6bf9c524ce9d" +SHA2 = "a" * 64 + + +def test_build_is_readable_and_content_addressed(): + c = build_commitment(SHA, hotkey=HK) + assert c == f"ralph-submission:{HK[:16]}-{SHA[:16]}:{SHA}" + assert COMMITMENT_RE.match(c) + sid, sha = parse_commitment(c) + assert sha == SHA and sid == derive_submission_id(HK, SHA) + + +def test_overwrite_is_update_not_race(): + # Same hotkey, new content -> a different, deterministic commitment. The + # single on-chain slot just holds whichever was set last (the current one). + c1 = build_commitment(SHA, hotkey=HK) + c2 = build_commitment(SHA2, hotkey=HK) + assert c1 != c2 + assert parse_commitment(c2)[1] == SHA2 + + +def test_verify_matches_the_right_bundle(): + c = build_commitment(SHA, hotkey=HK) + ok, _ = verify_commitment(c, hotkey=HK, bundle_sha256=SHA) + assert ok + + +def test_verify_rejects_wrong_bundle(): + c = build_commitment(SHA, hotkey=HK) + ok, reason = verify_commitment(c, hotkey=HK, bundle_sha256=SHA2) + assert not ok and "content hash" in reason + + +def test_verify_rejects_spoofed_submission_id(): + # A commitment whose id was derived for a DIFFERENT hotkey but carries this + # content must not verify for HK. + other = "5H6mytgBYJbgTRNQv11gGfhFa1Dq91rd8TiSZQnKW7cmB1jb" + spoof = f"ralph-submission:{derive_submission_id(other, SHA)}:{SHA}" + assert COMMITMENT_RE.match(spoof) + ok, reason = verify_commitment(spoof, hotkey=HK, bundle_sha256=SHA) + assert not ok and "canonical" in reason + + +def test_parse_rejects_malformed(): + for bad in ( + "", + "private-submission:x:" + SHA, # wrong prefix + f"ralph-submission:{HK[:16]}-{SHA[:16]}", # missing hash + f"ralph-submission:bad id:{SHA}", # space in id + f"ralph-submission:x:{SHA.upper()}", # uppercase hash + f"ralph-submission:x:{SHA[:63]}", # short hash + ): + with pytest.raises(ValueError): + parse_commitment(bad) + + +def test_build_rejects_bad_sha(): + with pytest.raises(ValueError): + build_commitment("nothex", hotkey=HK) + + +def test_case_insensitive_sha_normalizes(): + c = build_commitment(SHA.upper(), hotkey=HK) + assert parse_commitment(c)[1] == SHA # lowercased + ok, _ = verify_commitment(c, hotkey=HK, bundle_sha256=SHA.upper()) + assert ok + + +def test_signature_payload_shape(): + sid = derive_submission_id(HK, SHA) + assert signature_payload(HK, sid, SHA) == f"ralph-submission-v1:{HK}:{sid}:{SHA}".encode() diff --git a/validator/inflight.py b/validator/inflight.py new file mode 100644 index 0000000..04132f1 --- /dev/null +++ b/validator/inflight.py @@ -0,0 +1,99 @@ +"""One-in-flight-submission-per-hotkey guard (a dynamic cooldown). + +NOT Ninja's one-shot rule (a hotkey is never "spent"). Instead: a hotkey may +have at most ONE submission being evaluated at a time. The moment that +submission is SCORED, the hotkey can submit again — zero penalty on honest fast +iterators, but the validator never runs more than one expensive op4 per hotkey +concurrently, and a miner can't overwrite/replace a submission mid-evaluation +and waste GPU. + +This complements (does not replace) the on-chain `set_commitment` rate-limit and +op1 novelty-rejection. State is file-backed so it survives validator restarts. + +Usage in the submission loop: + guard = InFlightGuard(chain_dir / "inflight.json") + ok, reason = guard.claim(hotkey, bundle_hash) + if not ok: + defer(bundle); continue # try again a later epoch + try: + result = judge_submission(...) # op1..op4 + ... score / crown ... + finally: + guard.release(hotkey, bundle_hash) +""" +from __future__ import annotations + +import json +from pathlib import Path + + +class InFlightGuard: + def __init__(self, state_path: Path | str): + self.path = Path(state_path) + self._state: dict[str, str] = self._load() + + def _load(self) -> dict[str, str]: + if self.path.exists(): + try: + d = json.loads(self.path.read_text()) + if isinstance(d, dict): + return {str(k): str(v) for k, v in d.items()} + except Exception: # noqa: BLE001 — corrupt state must not wedge the validator + return {} + return {} + + def _save(self) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + self.path.write_text(json.dumps(self._state, indent=2, sort_keys=True)) + + def in_flight(self, hotkey: str) -> str | None: + """The bundle_hash currently in flight for this hotkey, or None.""" + return self._state.get(hotkey) + + def claim(self, hotkey: str, bundle_hash: str) -> tuple[bool, str]: + """Mark (hotkey, bundle) as in flight. + + OK if the hotkey has nothing in flight, or already has THIS bundle in + flight (idempotent — safe across restarts / VALIDATOR_VERSION reprocess). + Rejected if a DIFFERENT bundle is in flight — that one must be scored + (released) first. Rejection is a DEFER, not a permanent reject: the + caller should leave the bundle queued and retry a later epoch. + """ + cur = self._state.get(hotkey) + if cur is not None and cur != bundle_hash: + return False, ( + f"hotkey has submission {cur[:16]}… in flight — one per hotkey at a " + f"time; it must be scored before {bundle_hash[:16]}… is evaluated" + ) + if cur != bundle_hash: + self._state[hotkey] = bundle_hash + self._save() + return True, "claimed" + + def reconcile(self, valid_bundle_hashes: set[str]) -> None: + """Drop in-flight claims whose bundle is no longer in the pending set. + + Crash-recovery: if the validator died after a bundle was scored+archived + but before its claim was released, the stale claim would block that + hotkey forever. Called at epoch start with the current pending bundle + ids; any claim pointing at a bundle that's gone is cleared. + """ + stale = [hk for hk, b in self._state.items() if b not in valid_bundle_hashes] + if stale: + for hk in stale: + del self._state[hk] + self._save() + + def release(self, hotkey: str, bundle_hash: str | None = None) -> None: + """Clear the in-flight marker after the submission is scored. + + If bundle_hash is given, only release when it matches the in-flight one, + so a stale release can't clear a newer claim. + """ + cur = self._state.get(hotkey) + if cur is None: + return + if bundle_hash is not None and cur != bundle_hash: + return + del self._state[hotkey] + self._save() diff --git a/validator/service.py b/validator/service.py index f3c5e4f..a08e236 100644 --- a/validator/service.py +++ b/validator/service.py @@ -708,6 +708,16 @@ def _apply_pool_split( return weights +def _submission_hotkey(bundle_dir: Path) -> str | None: + """Cheap read of the claimed miner hotkey from a pending bundle (pre-op4).""" + try: + sub = json.loads((bundle_dir / "submission.json").read_text()) + hk = sub.get("miner_hotkey") + return str(hk) if hk else None + except Exception: + return None + + def run_epoch( chain, queue_dir: Path, @@ -801,8 +811,28 @@ def run_epoch( # and LocalChain expose .chain_dir. chain_dir = getattr(chain, "chain_dir", None) + # Dynamic per-hotkey cooldown ("one-in-flight"): at most one bundle per + # hotkey is evaluated per epoch; extras are DEFERRED (left queued) and retried + # once the in-flight one has been scored. NOT Ninja's one-shot rule — a hotkey + # can submit again next epoch. Opt-in via RALPH_ONE_IN_FLIGHT=1 (default off so + # pulling this never changes live behavior until enabled). No explicit release: + # a scored bundle leaves `pending`, so the next epoch's reconcile() frees the + # hotkey; reconcile also clears any claim orphaned by a mid-epoch crash. + inflight = None + if os.environ.get("RALPH_ONE_IN_FLIGHT", "0") == "1" and chain_dir: + from validator.inflight import InFlightGuard + inflight = InFlightGuard(Path(chain_dir) / "inflight.json") + inflight.reconcile({b.name for b in bundles}) + for bundle_dir in bundles: bundle_id = bundle_dir.name + if inflight is not None: + hk = _submission_hotkey(bundle_dir) + if hk: + ok, why = inflight.claim(hk, bundle_id) + if not ok: + log_info(f"deferring {bundle_id} (one-in-flight): {why}") + continue log_info(f"scoring {bundle_id}...") try: