openroboto-protocol 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (20) hide show
  1. {openroboto_protocol-0.2.0 → openroboto_protocol-0.3.0}/PKG-INFO +5 -5
  2. {openroboto_protocol-0.2.0 → openroboto_protocol-0.3.0}/README.md +4 -4
  3. {openroboto_protocol-0.2.0 → openroboto_protocol-0.3.0}/pyproject.toml +1 -1
  4. openroboto_protocol-0.3.0/src/openroboto_protocol/commitment.py +468 -0
  5. openroboto_protocol-0.3.0/src/openroboto_protocol/constants.py +246 -0
  6. openroboto_protocol-0.3.0/src/openroboto_protocol/model_format.py +434 -0
  7. openroboto_protocol-0.3.0/src/openroboto_protocol/model_hash.py +135 -0
  8. openroboto_protocol-0.3.0/src/openroboto_protocol/schemas.py +1994 -0
  9. openroboto_protocol-0.3.0/src/openroboto_protocol/seed.py +151 -0
  10. openroboto_protocol-0.3.0/src/openroboto_protocol/status.py +359 -0
  11. openroboto_protocol-0.2.0/src/openroboto_protocol/commitment.py +0 -402
  12. openroboto_protocol-0.2.0/src/openroboto_protocol/constants.py +0 -164
  13. openroboto_protocol-0.2.0/src/openroboto_protocol/model_format.py +0 -376
  14. openroboto_protocol-0.2.0/src/openroboto_protocol/model_hash.py +0 -104
  15. openroboto_protocol-0.2.0/src/openroboto_protocol/schemas.py +0 -1666
  16. openroboto_protocol-0.2.0/src/openroboto_protocol/seed.py +0 -116
  17. openroboto_protocol-0.2.0/src/openroboto_protocol/status.py +0 -285
  18. {openroboto_protocol-0.2.0 → openroboto_protocol-0.3.0}/LICENSE +0 -0
  19. {openroboto_protocol-0.2.0 → openroboto_protocol-0.3.0}/src/openroboto_protocol/__init__.py +0 -0
  20. {openroboto_protocol-0.2.0 → openroboto_protocol-0.3.0}/src/openroboto_protocol/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openroboto-protocol
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Shared protocol contract for the OpenRoboto subnet on Bittensor (netuid 80): seed derivation, commitment codec, model hashing, status vocabulary and API schemas.
5
5
  Keywords: bittensor,openroboto,subnet,protocol
6
6
  Author: OpenRoboto
@@ -31,9 +31,9 @@ Pin the exact version. A floating range means two sides of the subnet can resolv
31
31
  to different code, which is the failure this package was created to prevent.
32
32
 
33
33
  ```bash
34
- uv add "openroboto-protocol==0.2.0"
34
+ uv add "openroboto-protocol==0.3.0"
35
35
  # or
36
- pip install "openroboto-protocol==0.2.0"
36
+ pip install "openroboto-protocol==0.3.0"
37
37
  ```
38
38
 
39
39
  ```python
@@ -51,7 +51,7 @@ seed and decode a commitment; that must not cost a `pydantic-core` wheel build o
51
51
  GPU box. Only `schemas.py` needs pydantic, and only the backend needs `schemas.py`:
52
52
 
53
53
  ```bash
54
- uv add "openroboto-protocol[schemas]==0.2.0" # backend only
54
+ uv add "openroboto-protocol[schemas]==0.3.0" # backend only
55
55
  ```
56
56
 
57
57
  ## Why this package exists
@@ -121,7 +121,7 @@ The version number *is* the contract version.
121
121
  | `minor` | New optional field. Old data missing the key **must** have a default |
122
122
  | `major` | Breaking change. Requires an on-chain data migration plan and review |
123
123
 
124
- Consumers pin an exact version (`openroboto-protocol==0.2.0`). Floating versions are
124
+ Consumers pin an exact version (`openroboto-protocol==0.3.0`). Floating versions are
125
125
  rejected in CI, as is any vendored copy of this code.
126
126
 
127
127
  ## What consumers must add to their own CI
@@ -9,9 +9,9 @@ Pin the exact version. A floating range means two sides of the subnet can resolv
9
9
  to different code, which is the failure this package was created to prevent.
10
10
 
11
11
  ```bash
12
- uv add "openroboto-protocol==0.2.0"
12
+ uv add "openroboto-protocol==0.3.0"
13
13
  # or
14
- pip install "openroboto-protocol==0.2.0"
14
+ pip install "openroboto-protocol==0.3.0"
15
15
  ```
16
16
 
17
17
  ```python
@@ -29,7 +29,7 @@ seed and decode a commitment; that must not cost a `pydantic-core` wheel build o
29
29
  GPU box. Only `schemas.py` needs pydantic, and only the backend needs `schemas.py`:
30
30
 
31
31
  ```bash
32
- uv add "openroboto-protocol[schemas]==0.2.0" # backend only
32
+ uv add "openroboto-protocol[schemas]==0.3.0" # backend only
33
33
  ```
34
34
 
35
35
  ## Why this package exists
@@ -99,7 +99,7 @@ The version number *is* the contract version.
99
99
  | `minor` | New optional field. Old data missing the key **must** have a default |
100
100
  | `major` | Breaking change. Requires an on-chain data migration plan and review |
101
101
 
102
- Consumers pin an exact version (`openroboto-protocol==0.2.0`). Floating versions are
102
+ Consumers pin an exact version (`openroboto-protocol==0.3.0`). Floating versions are
103
103
  rejected in CI, as is any vendored copy of this code.
104
104
 
105
105
  ## What consumers must add to their own CI
@@ -10,7 +10,7 @@ name = "openroboto-protocol"
10
10
  # (0.1.0 → 0.2.0)这样收紧 schemas 的哨兵默认值是允许的。
11
11
  #
12
12
  # ⚠️ 0.1.0 已经发到 PyPI(2026-08-19,OIDC)。PyPI 版本号不可重用,只能往前发。
13
- version = "0.2.0"
13
+ version = "0.3.0"
14
14
  description = "Shared protocol contract for the OpenRoboto subnet on Bittensor (netuid 80): seed derivation, commitment codec, model hashing, status vocabulary and API schemas."
15
15
  readme = "README.md"
16
16
  license = "MIT"
@@ -0,0 +1,468 @@
1
+ """Encoding and decoding of the on-chain commitment payload — miners write it,
2
+ the backend reads it, and the two sides must agree byte for byte.
3
+
4
+ A miner stuffs the metadata JSON of one submission into Bittensor's
5
+ `Commitments.set_commitment` (`Data::BigRaw`), and the backend scans the chain
6
+ and reads it back. **This one JSON is the submission itself** — it decides
7
+ which HF repo gets evaluated, which round it counts for, and which burn
8
+ transaction is used to check it. If the encoding side and the decoding side
9
+ disagree, the miner burned TAO and nobody saw it.
10
+
11
+ ## The shape of the on-chain JSON
12
+
13
+ The key names are squeezed down to one letter not for looks: `Data::BigRaw` has
14
+ a hard limit of 512 bytes, and one hf_repo_id plus two 64-character hex hashes
15
+ already eat half of it. Changing a key name = major.
16
+
17
+ | Key | Field | Contract meaning |
18
+ |---|---|---|
19
+ | `s` | `hotkey_ss58` | miner hotkey, full SS58 string |
20
+ | `h` | `block_hash` | submission block hash, **self-reported**, untrusted, see below |
21
+ | `c` | `hf_commit` | HuggingFace commit SHA, 40 hex characters |
22
+ | `r` | `round_num` | round number |
23
+ | `i` | `hf_repo_id` | HuggingFace repo id, e.g. `kyleab/pi05-scmGbsBoEmiQ` |
24
+ | `b` | `burn_tx_hash` | burn transaction hash, **stored on chain without `0x`** |
25
+ | `bb` | `burn_block` | the block the burn is in; encoded as `null` when it is 0 |
26
+
27
+ ## Two asymmetries that must be remembered
28
+
29
+ 1. **`h` is self-reported by the miner and must not be fed straight into seed
30
+ derivation.** Once the chain scanner has `commit_block` it must overwrite it
31
+ with `get_block_hash(commit_block)` — the one the chain gives has the `0x`,
32
+ the one the miner self-reports does not. And `derive_seed()` takes a sha256
33
+ of a string, so two extra characters mean a different seed and a different
34
+ set of evaluation tasks. This module **returns the miner's self-reported
35
+ value as-is** (it does not add `0x`); overwriting it is the caller's
36
+ responsibility.
37
+ 2. **`b` goes the other way: decoding adds `0x`, encoding strips `0x`.** Both
38
+ production consumers (backend/chain_scanner.py, scripts/seed_data.py) do it
39
+ this way, and the deduplication key depends on it.
40
+
41
+ ## The variant name is a length, not a version number
42
+
43
+ The field keys that come back from the SDK look like this: `BigRaw` / `Raw119`
44
+ / `Raw82`. The N in `RawN` is the **number of bytes**; up to 128 bytes it is
45
+ `RawN`, and only beyond that is it `BigRaw`. It carries no client version
46
+ information whatsoever.
47
+
48
+ The 2026-08 conclusion that "three of UID 71's four submissions were Raw119, so
49
+ the old rt.py took the Raw branch" was a misjudgement. The raw on-chain data
50
+ (Taostats extrinsic API, re-checked 2026-08-17):
51
+
52
+ 8797897 Raw119 netuid=126 ← a different subnet
53
+ 8808332 BigRaw netuid=80 ← this subnet, decoded normally by the backend
54
+ 8830097 Raw119 netuid=126 ← a different subnet
55
+ 8830168 Raw119 netuid=126 ← a different subnet
56
+
57
+ The same hotkey posts commitments on several subnets; those three Raw119
58
+ records are netuid 126's binary payloads, which the netuid 80 backend was never
59
+ supposed to see in the first place. The root cause of the misjudgement is that
60
+ Taostats **silently ignores** the `netuid` query parameter (measured: passing
61
+ `netuid=80` still returns records from subnets 123/126/68/47 and others), so an
62
+ offline analysis script pulling data by signer_address counted other subnets in.
63
+ The miner did not burn TAO for nothing.
64
+
65
+ Therefore this module does **no special handling for `Raw119`**: every `Raw*` /
66
+ `BigRaw` variant is decoded with the same JSON rules, and when decoding fails
67
+ `DecodeFailure` says which class of failure it was and carries the variant name
68
+ back to the caller for logging. The real signal of version drift is
69
+ `UNKNOWN_SCHEMA` (it is a JSON object but not a single known key appears in
70
+ it), not the variant name.
71
+
72
+ ## What it is not responsible for
73
+
74
+ It does not touch the chain, does not look up netuid, does not verify the burn,
75
+ does not overwrite `h`, and does not decide whether a round is the current one
76
+ — all of those need I/O or backend configuration and stay in the caller.
77
+
78
+ ## Who consumes it
79
+
80
+ * the miner CLI (`rt.py` / `openroboto submit`) — `encode()`, called before
81
+ burning TAO;
82
+ * the backend chain scanner — `decode()`, the return value of
83
+ `get_commitment_metadata()` is fed straight in;
84
+ * the historical backfill script — `decode()`, fed the `__kind`/`value` shape
85
+ returned by the indexer.
86
+
87
+ ## Minimal verification
88
+
89
+ `uv run pytest tests/test_commitment.py` — GV-1 in there is the 295 real bytes
90
+ of on-chain block 8808332, and the moment "encode and decode are inverses of
91
+ each other" goes red, the format has drifted.
92
+ """
93
+
94
+ from __future__ import annotations
95
+
96
+ import json
97
+ from collections.abc import Iterator, Mapping
98
+ from dataclasses import dataclass
99
+ from enum import StrEnum
100
+ from typing import Any, Final
101
+
102
+ # The hard limit of `Data::BigRaw`. An extrinsic longer than this is rejected by
103
+ # the chain — and the burn is spent before the submission, so it must be caught
104
+ # before the money is spent.
105
+ MAX_COMMITMENT_BYTES: Final = 512
106
+
107
+ # All known keys of the on-chain JSON. At decode time, "not one of them is
108
+ # recognized" = the other side is not a client of this protocol.
109
+ PAYLOAD_KEYS: Final = ("s", "h", "c", "r", "i", "b", "bb")
110
+
111
+
112
+ class DecodeFailure(StrEnum):
113
+ """Classification of decode failures — the caller uses it to tell "noise"
114
+ apart from "something actually broke".
115
+
116
+ Measured in production (2026-08-14, 3000 lines of chain-scan logs): of 772
117
+ decode failures, 768 were `NO_COMMITMENT` (that hotkey never submitted
118
+ anything on chain at all) and 4 were `NOT_UTF8` (binary payloads from other
119
+ subnets). The old scanner logged both of these classes together with real
120
+ faults as a `decode FAILED` WARNING, so 768 lines of noise buried the real
121
+ signal.
122
+ """
123
+
124
+ NO_COMMITMENT = "no_commitment"
125
+ """There is no such commitment on chain: `None`, an empty string, or no
126
+ usable field in `info.fields`.
127
+ This is a **normal state**, not an error — the vast majority of hotkeys
128
+ have never submitted anything."""
129
+
130
+ NOT_HEX = "not_hex"
131
+ """The field value declares itself as `0x...` but is not valid
132
+ hexadecimal."""
133
+
134
+ NOT_UTF8 = "not_utf8"
135
+ """The byte stream is not UTF-8. It is safe to conclude it is a binary
136
+ payload from another subnet."""
137
+
138
+ NOT_JSON = "not_json"
139
+ """It is UTF-8 text but not valid JSON."""
140
+
141
+ NOT_OBJECT = "not_object"
142
+ """It is valid JSON but the top level is not an object (an array or a
143
+ number, say)."""
144
+
145
+ UNKNOWN_SCHEMA = "unknown_schema"
146
+ """It is a JSON object, but not a single one of `PAYLOAD_KEYS` appears.
147
+ **This is the real signal of client version drift** — the other side is
148
+ using a set of key names we do not know."""
149
+
150
+
151
+ class CommitmentDecodeError(ValueError):
152
+ """Decoding failed. `reason` is for classification statistics, `detail` is
153
+ for humans to read."""
154
+
155
+ def __init__(self, reason: DecodeFailure, detail: str = "") -> None:
156
+ super().__init__(f"{reason.value}: {detail}" if detail else reason.value)
157
+ self.reason = reason
158
+ self.detail = detail
159
+
160
+
161
+ class CommitmentTooLargeError(ValueError):
162
+ """The encoded result exceeds `MAX_COMMITMENT_BYTES`, so this commitment
163
+ cannot go on chain.
164
+
165
+ It must be raised **before** the miner burns TAO: the burn happens first
166
+ and the submission second, and a failed submission does not give the money
167
+ back. In practice the only field that can blow the limit is `hf_repo_id`.
168
+ """
169
+
170
+ def __init__(self, size: int) -> None:
171
+ super().__init__(
172
+ f"commitment payload {size} bytes > {MAX_COMMITMENT_BYTES} limit; "
173
+ f"shorten hf_repo_id"
174
+ )
175
+ self.size = size
176
+
177
+
178
+ @dataclass(frozen=True)
179
+ class CommitmentPayload:
180
+ """All the on-chain fields of one submission — they must come from the same
181
+ source, so they are bound into one immutable whole.
182
+
183
+ Passing them as separate arguments would make "this block_hash with that
184
+ burn_tx_hash" possible, and once those two are mismatched the seed and the
185
+ burn check point at two different submissions.
186
+ """
187
+
188
+ hotkey_ss58: str
189
+ """Miner hotkey (SS58). On chain: `s`."""
190
+
191
+ block_hash: str
192
+ """The submission block hash **as self-reported by the miner**, on chain
193
+ `h`, without the `0x`.
194
+
195
+ ⚠️ Untrusted. The chain scanner must overwrite it with
196
+ `get_block_hash(commit_block)` before it is used to derive the seed,
197
+ otherwise miners can pick their own seed."""
198
+
199
+ hf_commit: str
200
+ """HuggingFace commit SHA, on chain `c`, 40 hex characters.
201
+ Empty values have occurred in production (the miner did not fill in the
202
+ commit URL); this module does not block it, the backend's HF check
203
+ rejects it."""
204
+
205
+ round_num: int
206
+ """Round number, on chain `r`. When missing it is treated as 0 — 0 is never
207
+ equal to the current round, so it gets rejected by the backend."""
208
+
209
+ hf_repo_id: str
210
+ """HuggingFace repo id, on chain `i`, e.g. `kyleab/pi05-scmGbsBoEmiQ`."""
211
+
212
+ burn_tx_hash: str
213
+ """Burn transaction hash, on chain `b`.
214
+
215
+ What is stored on chain does **not** have the `0x`; `decode()` adds `0x`
216
+ when it decodes and `encode()` strips it when it writes back. The
217
+ deduplication key (hotkey + round + burn_tx_hash) depends on this
218
+ normalized form."""
219
+
220
+ burn_block: int
221
+ """The block the burn is in, on chain `bb`. 0 means the miner did not report
222
+ it, and it is written as `null` when encoding."""
223
+
224
+
225
+ @dataclass(frozen=True)
226
+ class DecodedCommitment:
227
+ """The result of `decode()`: the payload self-reported by the miner, plus
228
+ the trustworthy part that the chain envelope provides.
229
+
230
+ The two are kept apart because their trust levels differ: everything in
231
+ `payload` was written by the miner, whereas `commit_block` was returned by
232
+ the chain node.
233
+ """
234
+
235
+ payload: CommitmentPayload
236
+ """The JSON the miner wrote on chain. Every field is a self-reported
237
+ value."""
238
+
239
+ commit_block: int
240
+ """The number of the block containing the commitment, from the metadata
241
+ envelope returned by the chain (trustworthy).
242
+ The caller takes it to `get_block_hash()` to exchange it for the real block
243
+ hash. It is 0 when the input has no envelope."""
244
+
245
+ data_variant: str
246
+ """The `Data` variant name reported by the SDK: `BigRaw` / `Raw119` / ….
247
+
248
+ It is only a **byte-length label**, not a version number (see the module
249
+ docstring). It is carried back so that the caller can see shape changes in
250
+ the logs; do not branch on it.
251
+ Empty string when bytes or a dict were passed directly."""
252
+
253
+
254
+ def encode(payload: CommitmentPayload) -> bytes:
255
+ """Encode the payload into the bytes that go on chain. The key order and
256
+ the separators are part of the contract and must not be changed.
257
+
258
+ The compact form of `json.dumps(separators=(",", ":"))` plus the fixed key
259
+ order `s,h,c,r,i,b,bb` is an established fact of the historical on-chain
260
+ data (see tests/test_golden_vectors.py). A different key order would not
261
+ fail to decode, but the bytes would no longer match — and any check that
262
+ compares byte by byte would blow up.
263
+
264
+ Beyond 512 bytes it raises `CommitmentTooLargeError`: such a payload cannot
265
+ go on chain, and by that point the miner has already burned TAO.
266
+ """
267
+ data = {
268
+ "s": payload.hotkey_ss58,
269
+ "h": _strip_0x(payload.block_hash),
270
+ "c": payload.hf_commit,
271
+ "r": payload.round_num,
272
+ "i": payload.hf_repo_id,
273
+ "b": _strip_0x(payload.burn_tx_hash),
274
+ # Writing 0 as null is the historical shape, not a typo.
275
+ "bb": payload.burn_block or None,
276
+ }
277
+ blob = json.dumps(data, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
278
+ if len(blob) > MAX_COMMITMENT_BYTES:
279
+ raise CommitmentTooLargeError(len(blob))
280
+ return blob
281
+
282
+
283
+ def decode(raw: object) -> DecodedCommitment:
284
+ """Decode whatever the chain scan produced into a `DecodedCommitment`;
285
+ raise `CommitmentDecodeError` on failure.
286
+
287
+ It accepts every shape the SDK actually returns (in production not one of
288
+ them can be left out):
289
+
290
+ * `bytes` / `str` — already the payload itself;
291
+ * `{"deposit":…, "block": N, "info": {"fields": [{"BigRaw": …}]}}`
292
+ — the return value of `get_commitment_metadata()`; the field value may be
293
+ JSON text, a `0x` hex string, or a tuple of integers, depending on the
294
+ SDK version;
295
+ * `{"__kind": "BigRaw", "value": "0x…"}` — the shape used by indexers
296
+ (Taostats/subsquid);
297
+ * the payload dict itself — the fallback path for old code.
298
+
299
+ Only the **first** usable `Raw*` / `BigRaw` field is taken. One commitment
300
+ on chain has exactly one field, and several of them means the shape is no
301
+ longer one we recognize; rather than guess, let the check above report it.
302
+ """
303
+ if raw is None:
304
+ raise CommitmentDecodeError(DecodeFailure.NO_COMMITMENT, "raw is None")
305
+
306
+ if isinstance(raw, bytes | bytearray):
307
+ if not raw:
308
+ raise CommitmentDecodeError(DecodeFailure.NO_COMMITMENT, "empty bytes")
309
+ return DecodedCommitment(_payload_from_bytes(bytes(raw)), 0, "")
310
+
311
+ if isinstance(raw, str):
312
+ # Measured in production: for a hotkey with no commitment, the SDK
313
+ # returns an empty string rather than None.
314
+ if not raw:
315
+ raise CommitmentDecodeError(DecodeFailure.NO_COMMITMENT, "empty string")
316
+ return DecodedCommitment(_payload_from_bytes(raw.encode("utf-8")), 0, "")
317
+
318
+ if isinstance(raw, Mapping):
319
+ commit_block = _as_int(raw.get("block"))
320
+ for variant, value in _iter_data_fields(raw):
321
+ blob = _field_bytes(value)
322
+ if blob:
323
+ return DecodedCommitment(
324
+ _payload_from_bytes(blob), commit_block, variant
325
+ )
326
+ # Fall back: the envelope has no field, but this dict itself looks like
327
+ # a payload.
328
+ if _has_known_key(raw):
329
+ return DecodedCommitment(_payload_from_mapping(raw), commit_block, "")
330
+ raise CommitmentDecodeError(
331
+ DecodeFailure.NO_COMMITMENT, "no Raw*/BigRaw field in info.fields"
332
+ )
333
+
334
+ raise CommitmentDecodeError(
335
+ DecodeFailure.NO_COMMITMENT, f"unsupported type {type(raw).__name__}"
336
+ )
337
+
338
+
339
+ # ─── Internal implementation ─────────────────────────────────
340
+
341
+
342
+ def _strip_0x(value: str) -> str:
343
+ return value[2:] if value.startswith("0x") else value
344
+
345
+
346
+ def _as_str(value: object) -> str:
347
+ """Anything that is not a string counts as empty — a miner writing
348
+ `"i": 123` gets rejected by the backend's HF check."""
349
+ return value if isinstance(value, str) else ""
350
+
351
+
352
+ def _as_int(value: object, default: int = 0) -> int:
353
+ """`None` / non-numeric → default. On chain, `bb` defaults to `null`."""
354
+ if isinstance(value, bool):
355
+ return default
356
+ if isinstance(value, int):
357
+ return value
358
+ if isinstance(value, str):
359
+ try:
360
+ return int(value)
361
+ except ValueError:
362
+ return default
363
+ return default
364
+
365
+
366
+ def _has_known_key(data: Mapping[Any, Any]) -> bool:
367
+ return any(key in data for key in PAYLOAD_KEYS)
368
+
369
+
370
+ def _iter_data_fields(raw: Mapping[Any, Any]) -> Iterator[tuple[str, object]]:
371
+ """Dig (variant name, field value) out of the chain envelope.
372
+
373
+ Across SDK versions `info.fields` is either `[{...}]` or `[[{...}]]`, and
374
+ both levels have to be accepted.
375
+ """
376
+ info = raw.get("info")
377
+ if not isinstance(info, Mapping):
378
+ return
379
+ fields = info.get("fields")
380
+ if not isinstance(fields, list | tuple):
381
+ return
382
+ for group in fields:
383
+ entries = (group,) if isinstance(group, Mapping) else group
384
+ if not isinstance(entries, list | tuple):
385
+ continue
386
+ for entry in entries:
387
+ if not isinstance(entry, Mapping):
388
+ continue
389
+ # Indexer shape: {"__kind": "BigRaw", "value": "0x…"}
390
+ kind = entry.get("__kind")
391
+ if isinstance(kind, str) and _is_data_variant(kind):
392
+ yield kind, entry.get("value")
393
+ continue
394
+ # SDK shape: {"BigRaw": …}
395
+ for key, value in entry.items():
396
+ if isinstance(key, str) and _is_data_variant(key):
397
+ yield key, value
398
+
399
+
400
+ def _is_data_variant(key: str) -> bool:
401
+ """`RawN` (N = number of bytes, ≤128) or `BigRaw` (>128). N is not a version
402
+ number."""
403
+ return key == "BigRaw" or key.startswith("Raw")
404
+
405
+
406
+ def _field_bytes(value: object) -> bytes | None:
407
+ """Restore a field value back to bytes. Return `None` when it is not
408
+ recognized (and go on to the next field)."""
409
+ if isinstance(value, bytes | bytearray):
410
+ return bytes(value)
411
+ if isinstance(value, str):
412
+ if value.startswith("0x"):
413
+ try:
414
+ return bytes.fromhex(value[2:])
415
+ except ValueError as exc:
416
+ raise CommitmentDecodeError(DecodeFailure.NOT_HEX, str(exc)) from exc
417
+ return value.encode("utf-8")
418
+ if isinstance(value, list | tuple):
419
+ flat: list[int] = []
420
+ for item in value:
421
+ if isinstance(item, list | tuple):
422
+ flat.extend(i for i in item if isinstance(i, int))
423
+ elif isinstance(item, int):
424
+ flat.append(item)
425
+ try:
426
+ return bytes(flat)
427
+ except ValueError:
428
+ return None
429
+ return None
430
+
431
+
432
+ def _payload_from_bytes(blob: bytes) -> CommitmentPayload:
433
+ try:
434
+ text = blob.decode("utf-8")
435
+ except UnicodeDecodeError as exc:
436
+ raise CommitmentDecodeError(DecodeFailure.NOT_UTF8, str(exc)) from exc
437
+ try:
438
+ data = json.loads(text)
439
+ except ValueError as exc:
440
+ raise CommitmentDecodeError(DecodeFailure.NOT_JSON, str(exc)) from exc
441
+ if not isinstance(data, dict):
442
+ raise CommitmentDecodeError(
443
+ DecodeFailure.NOT_OBJECT, f"top level is {type(data).__name__}"
444
+ )
445
+ if not _has_known_key(data):
446
+ raise CommitmentDecodeError(
447
+ DecodeFailure.UNKNOWN_SCHEMA, f"keys={sorted(map(str, data))[:8]}"
448
+ )
449
+ return _payload_from_mapping(data)
450
+
451
+
452
+ def _payload_from_mapping(data: Mapping[Any, Any]) -> CommitmentPayload:
453
+ """A missing key always falls back to the default value — old data from
454
+ before a minor version added a new key must still decode."""
455
+ burn_tx_hash = _as_str(data.get("b"))
456
+ if burn_tx_hash and not burn_tx_hash.startswith("0x"):
457
+ burn_tx_hash = f"0x{burn_tx_hash}"
458
+ return CommitmentPayload(
459
+ hotkey_ss58=_as_str(data.get("s")),
460
+ # No `0x` is added: the miner's self-reported value is returned as-is,
461
+ # see the asymmetry note in the module docstring.
462
+ block_hash=_as_str(data.get("h")),
463
+ hf_commit=_as_str(data.get("c")),
464
+ round_num=_as_int(data.get("r")),
465
+ hf_repo_id=_as_str(data.get("i")),
466
+ burn_tx_hash=burn_tx_hash,
467
+ burn_block=_as_int(data.get("bb")),
468
+ )