Skip to content
This repository was archived by the owner on Aug 3, 2026. It is now read-only.
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
50 changes: 50 additions & 0 deletions chain_layer/bittensor_chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
109 changes: 109 additions & 0 deletions chain_layer/submission_commitment.py
Original file line number Diff line number Diff line change
@@ -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:<submission_id>:<sha256-of-agent.py>
("Only one accepted submission is eligible per miner hotkey registration.")

This module ports that pattern to Ralph. The commitment is::

ralph-submission:<submission_id>:<bundle_sha256>

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 ``<hotkey[:16]>-<sha[:16]>`` (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)"
71 changes: 71 additions & 0 deletions tests/test_inflight.py
Original file line number Diff line number Diff line change
@@ -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]
86 changes: 86 additions & 0 deletions tests/test_submission_commitment.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading