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.
- {openroboto_protocol-0.2.0 → openroboto_protocol-0.3.0}/PKG-INFO +5 -5
- {openroboto_protocol-0.2.0 → openroboto_protocol-0.3.0}/README.md +4 -4
- {openroboto_protocol-0.2.0 → openroboto_protocol-0.3.0}/pyproject.toml +1 -1
- openroboto_protocol-0.3.0/src/openroboto_protocol/commitment.py +468 -0
- openroboto_protocol-0.3.0/src/openroboto_protocol/constants.py +246 -0
- openroboto_protocol-0.3.0/src/openroboto_protocol/model_format.py +434 -0
- openroboto_protocol-0.3.0/src/openroboto_protocol/model_hash.py +135 -0
- openroboto_protocol-0.3.0/src/openroboto_protocol/schemas.py +1994 -0
- openroboto_protocol-0.3.0/src/openroboto_protocol/seed.py +151 -0
- openroboto_protocol-0.3.0/src/openroboto_protocol/status.py +359 -0
- openroboto_protocol-0.2.0/src/openroboto_protocol/commitment.py +0 -402
- openroboto_protocol-0.2.0/src/openroboto_protocol/constants.py +0 -164
- openroboto_protocol-0.2.0/src/openroboto_protocol/model_format.py +0 -376
- openroboto_protocol-0.2.0/src/openroboto_protocol/model_hash.py +0 -104
- openroboto_protocol-0.2.0/src/openroboto_protocol/schemas.py +0 -1666
- openroboto_protocol-0.2.0/src/openroboto_protocol/seed.py +0 -116
- openroboto_protocol-0.2.0/src/openroboto_protocol/status.py +0 -285
- {openroboto_protocol-0.2.0 → openroboto_protocol-0.3.0}/LICENSE +0 -0
- {openroboto_protocol-0.2.0 → openroboto_protocol-0.3.0}/src/openroboto_protocol/__init__.py +0 -0
- {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.
|
|
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.
|
|
34
|
+
uv add "openroboto-protocol==0.3.0"
|
|
35
35
|
# or
|
|
36
|
-
pip install "openroboto-protocol==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.
|
|
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.
|
|
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.
|
|
12
|
+
uv add "openroboto-protocol==0.3.0"
|
|
13
13
|
# or
|
|
14
|
-
pip install "openroboto-protocol==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.
|
|
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.
|
|
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.
|
|
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
|
+
)
|