recensus-sdk 1.0.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.
@@ -0,0 +1,44 @@
1
+ node_modules/
2
+ dist/
3
+ build/
4
+ .next/
5
+ out/
6
+ coverage/
7
+ .turbo/
8
+ *.tsbuildinfo
9
+ .env
10
+ .env.local
11
+ .env.*.local
12
+ !.env.example
13
+ .DS_Store
14
+ .data/
15
+ *.log
16
+ .vercel/
17
+ # Generated from config/chains.json at build time (scripts/gen-manifest.mjs).
18
+ # Unanchored on purpose: a leading path would be anchored to the repo root and
19
+ # would miss apps/web/public/, which is where they are actually written.
20
+ **/llms.txt
21
+ **/llms-full.txt
22
+ __pycache__/
23
+ *.egg-info/
24
+ .venv/
25
+ .pytest_cache/
26
+ cache/
27
+ broadcast/
28
+ .venv/
29
+ lh*.json
30
+
31
+ # Foundry
32
+ contracts/out/
33
+ contracts/cache/
34
+ contracts/lib/
35
+ # Run receipts, including local anvil runs. The deployed addresses that
36
+ # matter go in config/site.json, not here.
37
+ contracts/broadcast/
38
+
39
+ # Vercel CLI: the project link and the OIDC token it pulls.
40
+ #
41
+ # The CLI also appends a blanket `.env*` here, which would take `.env.example`
42
+ # with it — and CLAUDE.md rule 4 requires that one committed. The env rules at
43
+ # the top of this file already cover the real ones, so it is removed on sight.
44
+ .vercel/
@@ -0,0 +1,105 @@
1
+ Metadata-Version: 2.5
2
+ Name: recensus-sdk
3
+ Version: 1.0.0
4
+ Summary: Label your agent's transactions on Robinhood Chain so they show up on Recensus.
5
+ Project-URL: Homepage, https://recensus.xyz
6
+ Project-URL: Documentation, https://recensus.xyz/docs/get-counted
7
+ Project-URL: Specification, https://recensus.xyz/spec
8
+ License: MIT
9
+ Keywords: ai-agents,evm,recensus,robinhood-chain,web3
10
+ Requires-Python: >=3.11
11
+ Requires-Dist: eth-account>=0.11
12
+ Requires-Dist: eth-hash[pycryptodome]>=0.7
13
+ Requires-Dist: eth-utils>=4.0
14
+ Requires-Dist: web3<8,>=6.20
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest>=8.0; extra == 'dev'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # recensus-sdk
20
+
21
+ Label your agent's transactions on Robinhood Chain so they show up on
22
+ [Recensus](https://recensus.xyz). The Python mirror of `recensus-sdk` on npm —
23
+ same name, same surface, different registry.
24
+
25
+ ```bash
26
+ pip install recensus-sdk
27
+ ```
28
+
29
+ ```python
30
+ from recensus_sdk import Recensus, derive_agent_id
31
+ from web3 import Web3
32
+
33
+ w3 = Web3(Web3.HTTPProvider("https://rpc.mainnet.chain.robinhood.com"))
34
+
35
+ recensus = Recensus(
36
+ agent_id=derive_agent_id(operator_address, "price-watcher"),
37
+ autonomous=True, # no human approves each send
38
+ # test=True # in staging: excluded from every public number
39
+ w3=w3,
40
+ )
41
+
42
+ account = recensus.wrap(account)
43
+ account.send_transaction({"to": recipient, "value": 1_000_000})
44
+ ```
45
+
46
+ That is the whole integration. Every send now carries 24 bytes on the end of
47
+ its calldata, costing 372 gas, and the transaction appears on the public
48
+ scoreboard as your agent.
49
+
50
+ ## The label never breaks a transaction
51
+
52
+ Three layers, in order:
53
+
54
+ 1. **Call shape.** The label goes only where trailing calldata is inert.
55
+ Contract deployments, EntryPoint `handleOps`, data sent to an address with
56
+ no code, and anything marked `tagSafe: false` are refused outright.
57
+ 2. **Simulation.** Before sending, the labelled call is simulated. If it would
58
+ revert where the unlabelled one succeeds, the unlabelled call is sent and a
59
+ warning is raised. This is on by default and the standard forbids shipping
60
+ it off by default.
61
+ 3. **A catch-all.** Any unexpected failure while deciding sends unlabelled
62
+ rather than failing.
63
+
64
+ If both the labelled and unlabelled calls revert, your own calldata is sent, so
65
+ the error you see is yours and not ours.
66
+
67
+ ## Without web3
68
+
69
+ The label itself has no dependencies, so a reader or writer can live anywhere:
70
+
71
+ ```python
72
+ from recensus_sdk import build_label, parse_label
73
+
74
+ label = build_label("0x9f2a0c1e7b5d4a8f36c20e91d7b4a5c3", framework=0x0003)
75
+ data = existing_calldata + label[2:]
76
+
77
+ parse_label(data).agent_id # '0x9f2a0c1e7b5d4a8f36c20e91d7b4a5c3'
78
+ ```
79
+
80
+ `parse_label` returns `None` rather than raising for anything that is not a
81
+ well-formed label of a version it knows. A reader that guesses is a reader that
82
+ mislabels somebody's transaction.
83
+
84
+ ## The agent lane
85
+
86
+ ```python
87
+ headers = recensus.sign_request("POST", "https://api.example.com/v1/thing",
88
+ account, body={"hello": "world"})
89
+ requests.post(url, json=body, headers=headers)
90
+ ```
91
+
92
+ Five headers an app can verify with `requireRecensus` from `recensus-sdk` on
93
+ npm, so a
94
+ labelled agent can be given its own rate limits instead of being throttled like
95
+ a spam bot.
96
+
97
+ ## Development
98
+
99
+ ```bash
100
+ pip install -e '.[dev]'
101
+ pytest
102
+ ```
103
+
104
+ The test suite checks this implementation against the same vectors as the
105
+ TypeScript one, so the two cannot drift.
@@ -0,0 +1,87 @@
1
+ # recensus-sdk
2
+
3
+ Label your agent's transactions on Robinhood Chain so they show up on
4
+ [Recensus](https://recensus.xyz). The Python mirror of `recensus-sdk` on npm —
5
+ same name, same surface, different registry.
6
+
7
+ ```bash
8
+ pip install recensus-sdk
9
+ ```
10
+
11
+ ```python
12
+ from recensus_sdk import Recensus, derive_agent_id
13
+ from web3 import Web3
14
+
15
+ w3 = Web3(Web3.HTTPProvider("https://rpc.mainnet.chain.robinhood.com"))
16
+
17
+ recensus = Recensus(
18
+ agent_id=derive_agent_id(operator_address, "price-watcher"),
19
+ autonomous=True, # no human approves each send
20
+ # test=True # in staging: excluded from every public number
21
+ w3=w3,
22
+ )
23
+
24
+ account = recensus.wrap(account)
25
+ account.send_transaction({"to": recipient, "value": 1_000_000})
26
+ ```
27
+
28
+ That is the whole integration. Every send now carries 24 bytes on the end of
29
+ its calldata, costing 372 gas, and the transaction appears on the public
30
+ scoreboard as your agent.
31
+
32
+ ## The label never breaks a transaction
33
+
34
+ Three layers, in order:
35
+
36
+ 1. **Call shape.** The label goes only where trailing calldata is inert.
37
+ Contract deployments, EntryPoint `handleOps`, data sent to an address with
38
+ no code, and anything marked `tagSafe: false` are refused outright.
39
+ 2. **Simulation.** Before sending, the labelled call is simulated. If it would
40
+ revert where the unlabelled one succeeds, the unlabelled call is sent and a
41
+ warning is raised. This is on by default and the standard forbids shipping
42
+ it off by default.
43
+ 3. **A catch-all.** Any unexpected failure while deciding sends unlabelled
44
+ rather than failing.
45
+
46
+ If both the labelled and unlabelled calls revert, your own calldata is sent, so
47
+ the error you see is yours and not ours.
48
+
49
+ ## Without web3
50
+
51
+ The label itself has no dependencies, so a reader or writer can live anywhere:
52
+
53
+ ```python
54
+ from recensus_sdk import build_label, parse_label
55
+
56
+ label = build_label("0x9f2a0c1e7b5d4a8f36c20e91d7b4a5c3", framework=0x0003)
57
+ data = existing_calldata + label[2:]
58
+
59
+ parse_label(data).agent_id # '0x9f2a0c1e7b5d4a8f36c20e91d7b4a5c3'
60
+ ```
61
+
62
+ `parse_label` returns `None` rather than raising for anything that is not a
63
+ well-formed label of a version it knows. A reader that guesses is a reader that
64
+ mislabels somebody's transaction.
65
+
66
+ ## The agent lane
67
+
68
+ ```python
69
+ headers = recensus.sign_request("POST", "https://api.example.com/v1/thing",
70
+ account, body={"hello": "world"})
71
+ requests.post(url, json=body, headers=headers)
72
+ ```
73
+
74
+ Five headers an app can verify with `requireRecensus` from `recensus-sdk` on
75
+ npm, so a
76
+ labelled agent can be given its own rate limits instead of being throttled like
77
+ a spam bot.
78
+
79
+ ## Development
80
+
81
+ ```bash
82
+ pip install -e '.[dev]'
83
+ pytest
84
+ ```
85
+
86
+ The test suite checks this implementation against the same vectors as the
87
+ TypeScript one, so the two cannot drift.
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "recensus-sdk"
7
+ version = "1.0.0"
8
+ description = "Label your agent's transactions on Robinhood Chain so they show up on Recensus."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "MIT" }
12
+ keywords = ["robinhood-chain", "ai-agents", "recensus", "web3", "evm"]
13
+ # eth-hash needs an explicit backend: eth-utils declares the interface but not
14
+ # an implementation, and derive_agent_id works without web3, so it cannot be
15
+ # left to web3 to pull one in.
16
+ dependencies = [
17
+ "web3>=6.20,<8",
18
+ "eth-account>=0.11",
19
+ "eth-utils>=4.0",
20
+ "eth-hash[pycryptodome]>=0.7",
21
+ ]
22
+
23
+ [project.optional-dependencies]
24
+ dev = ["pytest>=8.0"]
25
+
26
+ [project.urls]
27
+ Homepage = "https://recensus.xyz"
28
+ Documentation = "https://recensus.xyz/docs/get-counted"
29
+ Specification = "https://recensus.xyz/spec"
30
+
31
+ [tool.hatch.build.targets.wheel]
32
+ packages = ["recensus_sdk"]
33
+
34
+ [tool.pytest.ini_options]
35
+ testpaths = ["tests"]
36
+ addopts = "-q"
@@ -0,0 +1,45 @@
1
+ """
2
+ recensus-sdk — label your agent's transactions on Robinhood Chain.
3
+
4
+ The Python mirror of ``@recensus/sdk``: the same 24-byte label, the same safety
5
+ rules, the same promise that the label never breaks a transaction.
6
+
7
+ from recensus_sdk import Recensus, derive_agent_id
8
+
9
+ recensus = Recensus(agent_id=derive_agent_id(operator, "price-watcher"),
10
+ autonomous=True, w3=w3)
11
+ account = recensus.wrap(account)
12
+ account.send_transaction({"to": recipient, "value": 1_000_000})
13
+ """
14
+
15
+ from .label import (
16
+ MAGIC, LABEL_BYTES, VERSION, FLAG_AUTONOMOUS, FLAG_TEST,
17
+ Label, LabelFlags, RecensusLabelError,
18
+ append_label, build_label, byte_to_flags, derive_agent_id, flags_to_byte,
19
+ has_label, normalize_agent_id, parse_label, short_agent_id, strip_label,
20
+ )
21
+ from .safety import (
22
+ NEVER_TAG_SELECTORS, CallShape, SafetyDecision, check_safety, selector_of,
23
+ )
24
+ from .frameworks import (
25
+ FRAMEWORK_UNKNOWN, FRAMEWORK_MCP, FRAMEWORK_SDK_TS, FRAMEWORK_SDK_PY,
26
+ framework_code_to_hex, framework_color_index,
27
+ )
28
+ from .recensus import Recensus, TagResult
29
+ from .sign import SIGNING_PREFIX, body_hash, canonical_string, random_nonce, sign_request
30
+
31
+ __version__ = "1.0.0"
32
+
33
+ __all__ = [
34
+ "MAGIC", "LABEL_BYTES", "VERSION", "FLAG_AUTONOMOUS", "FLAG_TEST",
35
+ "Label", "LabelFlags", "RecensusLabelError",
36
+ "append_label", "build_label", "byte_to_flags", "derive_agent_id",
37
+ "flags_to_byte", "has_label", "normalize_agent_id", "parse_label",
38
+ "short_agent_id", "strip_label",
39
+ "NEVER_TAG_SELECTORS", "CallShape", "SafetyDecision", "check_safety", "selector_of",
40
+ "FRAMEWORK_UNKNOWN", "FRAMEWORK_MCP", "FRAMEWORK_SDK_TS", "FRAMEWORK_SDK_PY",
41
+ "framework_code_to_hex", "framework_color_index",
42
+ "Recensus", "TagResult",
43
+ "SIGNING_PREFIX", "body_hash", "canonical_string", "random_nonce", "sign_request",
44
+ "__version__",
45
+ ]
@@ -0,0 +1,20 @@
1
+ """Framework registry helpers (RECENSUS-1 §2)."""
2
+
3
+ FRAMEWORK_UNKNOWN = 0x0000
4
+ FRAMEWORK_MCP = 0x0001
5
+ FRAMEWORK_SDK_TS = 0x0002
6
+ FRAMEWORK_SDK_PY = 0x0003
7
+
8
+
9
+ def framework_code_to_hex(code: int) -> str:
10
+ return f"0x{code:04x}"
11
+
12
+
13
+ def framework_color_index(code: int) -> int:
14
+ """
15
+ A stable colour index per framework code, matching the TypeScript side so a
16
+ framework keeps its colour wherever it is rendered.
17
+ """
18
+ if code == FRAMEWORK_UNKNOWN:
19
+ return 7
20
+ return ((code * 2654435761) & 0xFFFFFFFF) % 7
@@ -0,0 +1,225 @@
1
+ """
2
+ RECENSUS-1 label: build, parse, validate. See packages/spec/RECENSUS-1.md.
3
+
4
+ Layout, appended to the end of calldata:
5
+
6
+ | agentId (16) | framework (2) | flags (1) | version (1) | magic (4) |
7
+
8
+ This module mirrors ``packages/spec/src/label.ts`` byte for byte, and the test
9
+ suite checks the two against the same vectors. It has no dependencies on
10
+ purpose: a label reader should be copy-pasteable into anything.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass
16
+ from typing import Callable, Union
17
+
18
+ MAGIC = "50554c53" # "PULS"
19
+ MAGIC_BYTES = 4
20
+ LABEL_BYTES = 24
21
+ VERSION = 1
22
+
23
+ FLAG_AUTONOMOUS = 0b0000_0001
24
+ FLAG_TEST = 0b0000_0010
25
+ #: Bits 2-7 are reserved and must be zero (RECENSUS-1 §1.2).
26
+ FLAG_RESERVED_MASK = 0b1111_1100
27
+
28
+ HexLike = Union[str, bytes, bytearray]
29
+
30
+
31
+ class RecensusLabelError(ValueError):
32
+ """Raised when something that must be a label is not one."""
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class LabelFlags:
37
+ autonomous: bool = False
38
+ test: bool = False
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Label:
43
+ #: 16-byte hex, lowercase, 0x-prefixed.
44
+ agent_id: str
45
+ #: Framework code, 0-65535.
46
+ framework: int
47
+ flags: LabelFlags
48
+ #: The raw flags byte, as sent.
49
+ flags_byte: int
50
+ version: int
51
+
52
+
53
+ # --------------------------------------------------------------------- hex
54
+
55
+
56
+ def _to_hex_body(value: HexLike) -> str:
57
+ """The hex characters of a value, with no 0x and no case."""
58
+ if isinstance(value, (bytes, bytearray)):
59
+ return value.hex()
60
+ if not isinstance(value, str):
61
+ raise RecensusLabelError(f"expected hex or bytes, got {type(value).__name__}")
62
+ body = value[2:] if value.lower().startswith("0x") else value
63
+ if body and not all(c in "0123456789abcdefABCDEF" for c in body):
64
+ raise RecensusLabelError(f"{value!r} is not hex")
65
+ return body.lower()
66
+
67
+
68
+ def normalize_agent_id(agent_id: HexLike) -> str:
69
+ """Any accepted agent-id form as 0x + 32 lowercase hex characters."""
70
+ body = _to_hex_body(agent_id)
71
+ if len(body) != 32:
72
+ raise RecensusLabelError(
73
+ f"agentId must be exactly 16 bytes (32 hex characters), got {len(body) // 2} bytes"
74
+ )
75
+ return f"0x{body}"
76
+
77
+
78
+ # ------------------------------------------------------------------- build
79
+
80
+
81
+ def flags_to_byte(flags: Union[LabelFlags, int, None]) -> int:
82
+ if isinstance(flags, int):
83
+ if not 0 <= flags <= 0xFF:
84
+ raise RecensusLabelError(f"flags byte must be 0-255, got {flags}")
85
+ if flags & FLAG_RESERVED_MASK:
86
+ raise RecensusLabelError(
87
+ f"flags bits 2-7 are reserved and must be zero (RECENSUS-1 §1.2), got 0x{flags:02x}"
88
+ )
89
+ return flags
90
+ if flags is None:
91
+ return 0
92
+ byte = 0
93
+ if flags.autonomous:
94
+ byte |= FLAG_AUTONOMOUS
95
+ if flags.test:
96
+ byte |= FLAG_TEST
97
+ return byte
98
+
99
+
100
+ def byte_to_flags(byte: int) -> LabelFlags:
101
+ return LabelFlags(
102
+ autonomous=bool(byte & FLAG_AUTONOMOUS),
103
+ test=bool(byte & FLAG_TEST),
104
+ )
105
+
106
+
107
+ def build_label(
108
+ agent_id: HexLike,
109
+ framework: int = 0,
110
+ flags: Union[LabelFlags, int, None] = None,
111
+ version: int = VERSION,
112
+ ) -> str:
113
+ """The 24-byte label as 0x-prefixed hex."""
114
+ body = normalize_agent_id(agent_id)[2:]
115
+
116
+ if not isinstance(framework, int) or isinstance(framework, bool) or not 0 <= framework <= 0xFFFF:
117
+ raise RecensusLabelError(f"framework must be an integer 0-65535, got {framework!r}")
118
+ if not isinstance(version, int) or not 0 <= version <= 0xFF:
119
+ raise RecensusLabelError(f"version must be an integer 0-255, got {version!r}")
120
+
121
+ flags_byte = flags_to_byte(flags)
122
+ return f"0x{body}{framework:04x}{flags_byte:02x}{version:02x}{MAGIC}"
123
+
124
+
125
+ # ------------------------------------------------------------------- parse
126
+
127
+
128
+ def parse_label(calldata: Union[HexLike, None]) -> Union[Label, None]:
129
+ """
130
+ Read the RECENSUS-1 label off the end of calldata.
131
+
132
+ Returns ``None`` — never raises — for anything that is not a well-formed
133
+ label of a version we know. A reader that guesses is a reader that
134
+ mislabels somebody's transaction.
135
+ """
136
+ if calldata is None:
137
+ return None
138
+
139
+ try:
140
+ body = _to_hex_body(calldata)
141
+ except RecensusLabelError:
142
+ return None
143
+
144
+ if len(body) % 2 != 0:
145
+ return None
146
+ if len(body) < LABEL_BYTES * 2:
147
+ return None
148
+ if not body.endswith(MAGIC):
149
+ return None
150
+
151
+ tail = body[-LABEL_BYTES * 2:]
152
+ version = int(tail[38:40], 16)
153
+ if version != VERSION:
154
+ return None # unknown version: ignore, do not guess
155
+
156
+ flags_byte = int(tail[36:38], 16)
157
+ if flags_byte & FLAG_RESERVED_MASK:
158
+ return None # reserved bits set: malformed
159
+
160
+ return Label(
161
+ agent_id=f"0x{tail[0:32]}",
162
+ framework=int(tail[32:36], 16),
163
+ flags=byte_to_flags(flags_byte),
164
+ flags_byte=flags_byte,
165
+ version=version,
166
+ )
167
+
168
+
169
+ def has_label(calldata: Union[HexLike, None]) -> bool:
170
+ """True when calldata already carries a well-formed RECENSUS-1 label."""
171
+ return parse_label(calldata) is not None
172
+
173
+
174
+ def strip_label(calldata: HexLike) -> str:
175
+ """Calldata with its label removed, or unchanged when it has none."""
176
+ body = _to_hex_body(calldata)
177
+ if not has_label(calldata):
178
+ return f"0x{body}"
179
+ return f"0x{body[:-LABEL_BYTES * 2]}"
180
+
181
+
182
+ def append_label(calldata: Union[HexLike, None], label: str) -> str:
183
+ """
184
+ Append a label to calldata. Never stacks: calldata that is already
185
+ labelled comes back unchanged (RECENSUS-1 §4.2 rule 3).
186
+ """
187
+ body = "" if calldata is None else _to_hex_body(calldata)
188
+ if has_label(f"0x{body}"):
189
+ return f"0x{body}"
190
+ return f"0x{body}{_to_hex_body(label)}"
191
+
192
+
193
+ # --------------------------------------------------------- agent identity
194
+
195
+
196
+ def derive_agent_id(
197
+ operator: str,
198
+ agent_name: str,
199
+ keccak: Union[Callable[[bytes], bytes], None] = None,
200
+ ) -> str:
201
+ """
202
+ The recommended derivation (RECENSUS-1 §1.1)::
203
+
204
+ keccak256(operatorAddress ‖ agentName)[0:16]
205
+
206
+ ``keccak`` is injected so this module can be used without web3; when it is
207
+ omitted, ``eth_utils.keccak`` is imported lazily.
208
+ """
209
+ address = _to_hex_body(operator)
210
+ if len(address) != 40:
211
+ raise RecensusLabelError(f"operator must be a 20-byte address, got {len(address) // 2} bytes")
212
+
213
+ if keccak is None:
214
+ from eth_utils import keccak as _keccak # imported here so the label module stays dependency-free
215
+
216
+ keccak = _keccak
217
+
218
+ digest = keccak(bytes.fromhex(address) + agent_name.encode("utf-8"))
219
+ return f"0x{digest.hex()[:32]}"
220
+
221
+
222
+ def short_agent_id(agent_id: HexLike) -> str:
223
+ """Short display form: 0x9f2a0c1e…d7b4a5c3"""
224
+ full = normalize_agent_id(agent_id)
225
+ return f"{full[:10]}…{full[-8:]}"
@@ -0,0 +1,240 @@
1
+ """
2
+ recensus-sdk — the Python mirror of ``@recensus/sdk``.
3
+
4
+ The one rule this file exists to keep: **the label never breaks a
5
+ transaction** (RECENSUS-1 §4). Three layers enforce it.
6
+
7
+ 1. ``check_safety`` refuses call shapes where trailing calldata is not inert.
8
+ 2. Before sending, the labelled call is simulated. If it would revert and
9
+ the unlabelled call would not, the unlabelled call is sent.
10
+ 3. Any unexpected failure while deciding falls through to unlabelled.
11
+
12
+ Losing a row on a scoreboard is cheap. Losing a transaction is not.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import warnings
18
+ from dataclasses import dataclass
19
+ from typing import Any, Callable, Dict, Iterable, Optional
20
+
21
+ from .frameworks import FRAMEWORK_SDK_PY
22
+ from .label import (
23
+ HexLike,
24
+ LabelFlags,
25
+ append_label,
26
+ build_label,
27
+ derive_agent_id,
28
+ has_label,
29
+ normalize_agent_id,
30
+ parse_label,
31
+ )
32
+ from .safety import CallShape, check_safety, load_tag_safe_contracts
33
+ from .sign import sign_request
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class TagResult:
38
+ data: str
39
+ labelled: bool
40
+ reason: Optional[str] = None
41
+ detail: Optional[str] = None
42
+
43
+
44
+ class Recensus:
45
+ """
46
+ Label an agent's transactions.
47
+
48
+ recensus = Recensus(agent_id=..., autonomous=True, w3=w3)
49
+ account = recensus.wrap(account)
50
+ account.send_transaction({"to": recipient, "value": 1_000_000})
51
+ """
52
+
53
+ def __init__(
54
+ self,
55
+ agent_id: HexLike,
56
+ framework: int = FRAMEWORK_SDK_PY,
57
+ autonomous: bool = False,
58
+ test: bool = False,
59
+ w3: Any = None,
60
+ simulate_before_send: bool = True,
61
+ denylist: Optional[Iterable[str]] = None,
62
+ deny_selectors: Optional[Iterable[str]] = None,
63
+ on_unlabelled: Optional[Callable[[Dict[str, Any]], None]] = None,
64
+ logger: Optional[Callable[[str], None]] = None,
65
+ ) -> None:
66
+ self.agent_id = normalize_agent_id(agent_id)
67
+ self.framework = framework
68
+ self.flags = LabelFlags(autonomous=autonomous, test=test)
69
+ self.label = build_label(self.agent_id, framework, self.flags)
70
+ self.w3 = w3
71
+ #: RECENSUS-1 §4.3 forbids shipping this off by default.
72
+ self.simulate_before_send = simulate_before_send
73
+
74
+ self._denylist = list(denylist or [])
75
+ self._deny_selectors = list(deny_selectors or [])
76
+ self._on_unlabelled = on_unlabelled
77
+ self._log = logger if logger is not None else (lambda message: warnings.warn(message, stacklevel=2))
78
+ self._contracts = load_tag_safe_contracts()
79
+ self._code_cache: Dict[str, bool] = {}
80
+
81
+ # ---------------------------------------------------------- manual
82
+
83
+ def tag(self, calldata: Optional[HexLike]) -> str:
84
+ """Append the label. No safety check, no simulation: you asked."""
85
+ return append_label(calldata, self.label)
86
+
87
+ @staticmethod
88
+ def parse(calldata: Optional[HexLike]):
89
+ """Read a label back off any calldata."""
90
+ return parse_label(calldata)
91
+
92
+ # ---------------------------------------------------------- decide
93
+
94
+ def _has_code(self, address: str) -> Optional[bool]:
95
+ if self.w3 is None:
96
+ return None
97
+ key = address.lower()
98
+ if key in self._code_cache:
99
+ return self._code_cache[key]
100
+ try:
101
+ code = self.w3.eth.get_code(self.w3.to_checksum_address(address))
102
+ result = len(code) > 0
103
+ except Exception:
104
+ return None # unknown: let the shape rules and the simulation decide
105
+ self._code_cache[key] = result
106
+ return result
107
+
108
+ def decide(self, to: Optional[str], data: Optional[str] = "0x") -> TagResult:
109
+ """Whether this call may carry the label, from its shape."""
110
+ data = data or "0x"
111
+ to_has_code = self._has_code(to) if to else None
112
+
113
+ decision = check_safety(
114
+ CallShape(to=to, data=data, to_has_code=to_has_code),
115
+ contracts=self._contracts,
116
+ denylist=self._denylist,
117
+ deny_selectors=self._deny_selectors,
118
+ )
119
+ if not decision.safe:
120
+ return TagResult(data, False, decision.reason, decision.detail)
121
+ return TagResult(self.tag(data), True)
122
+
123
+ def simulate(self, to: str, tagged: str, untagged: str, value: int = 0, sender: Optional[str] = None) -> TagResult:
124
+ """
125
+ The backstop (RECENSUS-1 §4.3). If the labelled call reverts where the
126
+ unlabelled one succeeds, drop the label. If both revert, keep the
127
+ caller's own call so the error they see is their own.
128
+ """
129
+ if self.w3 is None:
130
+ return TagResult(tagged, True)
131
+
132
+ def call(data: str) -> None:
133
+ tx: Dict[str, Any] = {"to": self.w3.to_checksum_address(to), "data": data}
134
+ if value:
135
+ tx["value"] = value
136
+ if sender:
137
+ tx["from"] = self.w3.to_checksum_address(sender)
138
+ self.w3.eth.call(tx)
139
+
140
+ try:
141
+ call(tagged)
142
+ return TagResult(tagged, True)
143
+ except Exception as labelled_error:
144
+ try:
145
+ call(untagged)
146
+ except Exception:
147
+ return TagResult(tagged, True, "SIMULATION_REVERTED_BOTH")
148
+ return TagResult(untagged, False, "SIMULATION_REVERTED", str(labelled_error).split("\n")[0])
149
+
150
+ def prepare(self, to: Optional[str], data: Optional[str] = "0x", value: int = 0, sender: Optional[str] = None) -> TagResult:
151
+ """The full decide → simulate → fall back pipeline for one call."""
152
+ data = data or "0x"
153
+ try:
154
+ result = self.decide(to, data)
155
+ except Exception as error: # nothing about deciding may cost a transaction
156
+ self._warn(to, "DECIDE_FAILED", str(error))
157
+ return TagResult(data, False, "DECIDE_FAILED")
158
+
159
+ if not result.labelled:
160
+ self._warn(to, result.reason or "UNSAFE", result.detail)
161
+ return result
162
+
163
+ if self.simulate_before_send and self.w3 is not None and to is not None:
164
+ try:
165
+ simulated = self.simulate(to, result.data, data, value, sender)
166
+ except Exception as error:
167
+ # The simulation itself failed. That is not evidence the label
168
+ # is unsafe, but it is not evidence it is safe either.
169
+ self._warn(to, "SIMULATION_UNAVAILABLE", str(error))
170
+ return TagResult(data, False, "SIMULATION_UNAVAILABLE")
171
+ if not simulated.labelled:
172
+ self._warn(to, simulated.reason or "SIMULATION_REVERTED", simulated.detail)
173
+ return simulated
174
+
175
+ return result
176
+
177
+ # ------------------------------------------------------------ wrap
178
+
179
+ def wrap(self, account: Any) -> Any:
180
+ """
181
+ Wrap an ``eth_account`` LocalAccount so every ``send_transaction``
182
+ carries the label. Everything else passes through untouched.
183
+ """
184
+ recensus = self
185
+ w3 = self.w3
186
+ if w3 is None:
187
+ raise ValueError("Recensus(w3=...) is required before wrapping an account: sending needs a provider.")
188
+
189
+ class _WrappedAccount:
190
+ def __init__(self, inner: Any) -> None:
191
+ self._inner = inner
192
+
193
+ def __getattr__(self, name: str) -> Any:
194
+ return getattr(self._inner, name)
195
+
196
+ @property
197
+ def recensus(self) -> "Recensus":
198
+ return recensus
199
+
200
+ def send_transaction(self, tx: Dict[str, Any]) -> bytes:
201
+ prepared = recensus.prepare(
202
+ tx.get("to"),
203
+ tx.get("data", "0x"),
204
+ int(tx.get("value", 0) or 0),
205
+ self._inner.address,
206
+ )
207
+ outgoing = dict(tx)
208
+ outgoing["data"] = prepared.data
209
+ outgoing.setdefault("from", self._inner.address)
210
+ outgoing.setdefault("nonce", w3.eth.get_transaction_count(self._inner.address))
211
+ outgoing.setdefault("chainId", w3.eth.chain_id)
212
+ if "gas" not in outgoing:
213
+ outgoing["gas"] = w3.eth.estimate_gas(outgoing)
214
+ if "gasPrice" not in outgoing and "maxFeePerGas" not in outgoing:
215
+ outgoing["gasPrice"] = w3.eth.gas_price
216
+
217
+ signed = self._inner.sign_transaction(outgoing)
218
+ raw = getattr(signed, "raw_transaction", None) or getattr(signed, "rawTransaction")
219
+ return w3.eth.send_raw_transaction(raw)
220
+
221
+ return _WrappedAccount(account)
222
+
223
+ # ------------------------------------------------------ agent lane
224
+
225
+ def sign_request(self, method: str, url: str, account: Any, body: Any = None, **kwargs: Any) -> Dict[str, str]:
226
+ """Sign an HTTP request so an app can verify it came from this agent (SPEC §8)."""
227
+ return sign_request(self.agent_id, method, url, account, body, **kwargs)
228
+
229
+ # --------------------------------------------------------- private
230
+
231
+ def _warn(self, to: Optional[str], reason: str, detail: Optional[str] = None) -> None:
232
+ if self._on_unlabelled is not None:
233
+ self._on_unlabelled({"to": to, "reason": reason, "detail": detail})
234
+ self._log(
235
+ f"[recensus] sending unlabelled to {to or 'contract deployment'} — {reason}"
236
+ + (f": {detail}" if detail else "")
237
+ )
238
+
239
+
240
+ __all__ = ["Recensus", "TagResult", "derive_agent_id", "has_label"]
@@ -0,0 +1,122 @@
1
+ """
2
+ RECENSUS-1 §4 — when it is safe to append the label.
3
+
4
+ Mirrors ``packages/spec/src/safety.ts``. The rule the whole standard rests on
5
+ is that the label must never break a transaction; this module answers "may I
6
+ append here?" from the call shape alone, and the SDK simulates as a backstop.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Iterable, Optional, Set
15
+
16
+ from .label import has_label
17
+
18
+ #: Selectors that must never carry a trailing label, whatever the target: the
19
+ #: label belongs on the inner call, not on the wrapper.
20
+ NEVER_TAG_SELECTORS = (
21
+ "0x765e827f", # EntryPoint.handleOps (v0.7 / v0.8)
22
+ "0x1fad948c", # EntryPoint.handleOps (v0.6)
23
+ "0xdbed18e0", # EntryPoint.handleAggregatedOps (v0.6)
24
+ "0x4b1d7cf5", # EntryPoint.handleAggregatedOps (v0.7 / v0.8)
25
+ )
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class CallShape:
30
+ #: Target address; None for a contract deployment.
31
+ to: Optional[str] = None
32
+ #: Calldata as it would be sent, before the label.
33
+ data: str = "0x"
34
+ #: Whether `to` has code. None when the caller could not check.
35
+ to_has_code: Optional[bool] = None
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class SafetyDecision:
40
+ safe: bool
41
+ reason: Optional[str] = None
42
+ detail: Optional[str] = None
43
+
44
+
45
+ def selector_of(data: Optional[str]) -> Optional[str]:
46
+ if not data or len(data) < 10:
47
+ return None
48
+ return data[:10].lower()
49
+
50
+
51
+ def load_tag_safe_contracts(config_dir: Optional[Path] = None) -> dict:
52
+ """
53
+ ``config/labels.json``, when this package is used from inside the repo.
54
+
55
+ Published to PyPI there is no config directory, and that is fine: the call
56
+ shape rules plus the simulation are the real guard, and a missing config
57
+ file is never a reason to fail a send.
58
+ """
59
+ if config_dir is None:
60
+ here = Path(__file__).resolve()
61
+ for parent in here.parents:
62
+ candidate = parent / "config" / "labels.json"
63
+ if candidate.exists():
64
+ config_dir = candidate.parent
65
+ break
66
+ if config_dir is None:
67
+ return {}
68
+ try:
69
+ return json.loads((config_dir / "labels.json").read_text()).get("contracts", {})
70
+ except Exception:
71
+ return {}
72
+
73
+
74
+ def check_safety(
75
+ call: CallShape,
76
+ contracts: Optional[dict] = None,
77
+ denylist: Optional[Iterable[str]] = None,
78
+ deny_selectors: Optional[Iterable[str]] = None,
79
+ ) -> SafetyDecision:
80
+ contracts = contracts if contracts is not None else {}
81
+ deny: Set[str] = {a.lower() for a in (denylist or [])}
82
+ deny_sel: Set[str] = {s.lower() for s in (deny_selectors or [])}
83
+ data = call.data or "0x"
84
+
85
+ if has_label(data):
86
+ return SafetyDecision(False, "ALREADY_LABELLED", "calldata already carries a RECENSUS-1 label")
87
+
88
+ if call.to is None:
89
+ return SafetyDecision(
90
+ False,
91
+ "CONTRACT_DEPLOYMENT",
92
+ "a contract deployment is its own init code; a trailing label would become part of it",
93
+ )
94
+
95
+ to = call.to.lower()
96
+
97
+ if to in deny:
98
+ return SafetyDecision(False, "DENYLISTED", f"{to} is on the operator denylist")
99
+
100
+ known = contracts.get(to)
101
+ if known is not None and known.get("tagSafe") is False:
102
+ return SafetyDecision(False, "DENYLISTED", f"{known.get('name', to)} is marked tagSafe: false")
103
+
104
+ selector = selector_of(data)
105
+ if selector:
106
+ if selector in NEVER_TAG_SELECTORS:
107
+ return SafetyDecision(
108
+ False, "DENIED_SELECTOR", f"{selector} wraps other calls; label the inner call instead"
109
+ )
110
+ if selector in deny_sel:
111
+ return SafetyDecision(False, "DENIED_SELECTOR", f"{selector} is on the operator selector denylist")
112
+
113
+ # RECENSUS-1 §4.2 rule 1: sending real data to an address with no code means
114
+ # the data IS the message. A label would corrupt it.
115
+ if call.to_has_code is False and data not in ("0x", "", None) and len(data) > 2:
116
+ return SafetyDecision(
117
+ False,
118
+ "DATA_TO_EOA",
119
+ "target has no code and the call carries data; the label would be indistinguishable from that payload",
120
+ )
121
+
122
+ return SafetyDecision(True)
@@ -0,0 +1,74 @@
1
+ """
2
+ Agent-lane request signing (SPEC §8), the mirror of ``sdk-ts/src/sign.ts``.
3
+
4
+ The signed string is canonical and versioned so a server can reconstruct it
5
+ byte for byte::
6
+
7
+ recensus-v1\\n<METHOD>\\n<URL>\\n<timestamp>\\n<nonce>\\n<sha256(body) as hex>
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import json
14
+ import secrets
15
+ import time
16
+ from typing import Any, Dict, Optional, Union
17
+
18
+ SIGNING_PREFIX = "recensus-v1"
19
+
20
+ BodyLike = Union[str, bytes, bytearray, Dict[str, Any], None]
21
+
22
+
23
+ def canonical_body(body: BodyLike) -> bytes:
24
+ if body is None:
25
+ return b""
26
+ if isinstance(body, (bytes, bytearray)):
27
+ return bytes(body)
28
+ if isinstance(body, str):
29
+ return body.encode("utf-8")
30
+ # Separators without spaces, to match JSON.stringify.
31
+ return json.dumps(body, separators=(",", ":")).encode("utf-8")
32
+
33
+
34
+ def body_hash(body: BodyLike) -> str:
35
+ return "0x" + hashlib.sha256(canonical_body(body)).hexdigest()
36
+
37
+
38
+ def canonical_string(method: str, url: str, timestamp: int, nonce: str, body_sha256: str) -> str:
39
+ return "\n".join([SIGNING_PREFIX, method.upper(), url, str(timestamp), nonce, body_sha256])
40
+
41
+
42
+ def random_nonce() -> str:
43
+ return secrets.token_hex(16)
44
+
45
+
46
+ def sign_request(
47
+ agent_id: str,
48
+ method: str,
49
+ url: str,
50
+ account: Any,
51
+ body: BodyLike = None,
52
+ timestamp: Optional[int] = None,
53
+ nonce: Optional[str] = None,
54
+ ) -> Dict[str, str]:
55
+ """
56
+ Five headers an app can verify. ``account`` is an ``eth_account``
57
+ LocalAccount, or anything with ``address`` and ``sign_message``.
58
+ """
59
+ from eth_account.messages import encode_defunct
60
+
61
+ ts = timestamp if timestamp is not None else int(time.time())
62
+ n = nonce if nonce is not None else random_nonce()
63
+ message = canonical_string(method, url, ts, n, body_hash(body))
64
+ signed = account.sign_message(encode_defunct(text=message))
65
+
66
+ return {
67
+ "Recensus-Agent": agent_id,
68
+ "Recensus-Key": account.address,
69
+ "Recensus-Timestamp": str(ts),
70
+ "Recensus-Nonce": n,
71
+ "Recensus-Signature": signed.signature.hex()
72
+ if signed.signature.hex().startswith("0x")
73
+ else "0x" + signed.signature.hex(),
74
+ }
@@ -0,0 +1,177 @@
1
+ """
2
+ The Python label implementation, checked against the same expectations as the
3
+ TypeScript one.
4
+
5
+ ``test_parity.py`` runs the TypeScript implementation over the same vectors, so
6
+ the two cannot drift apart silently.
7
+ """
8
+
9
+ import pytest
10
+
11
+ from recensus_sdk import (
12
+ FLAG_AUTONOMOUS,
13
+ FLAG_TEST,
14
+ LABEL_BYTES,
15
+ MAGIC,
16
+ VERSION,
17
+ LabelFlags,
18
+ RecensusLabelError,
19
+ append_label,
20
+ build_label,
21
+ byte_to_flags,
22
+ derive_agent_id,
23
+ flags_to_byte,
24
+ has_label,
25
+ normalize_agent_id,
26
+ parse_label,
27
+ short_agent_id,
28
+ strip_label,
29
+ )
30
+
31
+ ID = "0x9f2a0c1e7b5d4a8f36c20e91d7b4a5c3"
32
+
33
+
34
+ class TestBuildLabel:
35
+ def test_produces_exactly_24_bytes_ending_in_the_magic(self):
36
+ label = build_label(ID, 0x0002, LabelFlags(autonomous=True))
37
+ assert len(label) == 2 + LABEL_BYTES * 2
38
+ assert label.endswith(MAGIC)
39
+ assert label == "0x9f2a0c1e7b5d4a8f36c20e91d7b4a5c30002010150554c53"
40
+
41
+ def test_defaults(self):
42
+ assert build_label(ID) == "0x9f2a0c1e7b5d4a8f36c20e91d7b4a5c30000000150554c53"
43
+
44
+ def test_accepts_bytes(self):
45
+ assert build_label(b"\xab" * 16) == f"0x{'ab' * 16}0000000150554c53"
46
+
47
+ def test_rejects_an_agent_id_that_is_not_16_bytes(self):
48
+ with pytest.raises(RecensusLabelError, match="exactly 16 bytes"):
49
+ build_label("0xdead")
50
+ with pytest.raises(RecensusLabelError):
51
+ build_label("0x" + "aa" * 17)
52
+
53
+ def test_rejects_an_out_of_range_framework(self):
54
+ with pytest.raises(RecensusLabelError, match="0-65535"):
55
+ build_label(ID, 0x1_0000)
56
+ with pytest.raises(RecensusLabelError, match="0-65535"):
57
+ build_label(ID, -1)
58
+
59
+ def test_rejects_reserved_flag_bits(self):
60
+ with pytest.raises(RecensusLabelError, match="reserved"):
61
+ build_label(ID, 0, 0b0000_0100)
62
+ with pytest.raises(RecensusLabelError, match="reserved"):
63
+ build_label(ID, 0, 0xFF)
64
+
65
+ @pytest.mark.parametrize(
66
+ "flags",
67
+ [LabelFlags(), LabelFlags(autonomous=True), LabelFlags(test=True), LabelFlags(True, True)],
68
+ )
69
+ def test_round_trips_every_legal_flag_combination(self, flags):
70
+ parsed = parse_label(build_label(ID, 0, flags))
71
+ assert parsed.flags == flags
72
+
73
+
74
+ class TestParseLabel:
75
+ label = build_label(ID, 0x0010, LabelFlags(autonomous=True, test=True))
76
+
77
+ def test_parses_a_bare_label(self):
78
+ parsed = parse_label(self.label)
79
+ assert parsed.agent_id == ID
80
+ assert parsed.framework == 0x0010
81
+ assert parsed.flags == LabelFlags(autonomous=True, test=True)
82
+ assert parsed.flags_byte == FLAG_AUTONOMOUS | FLAG_TEST
83
+ assert parsed.version == VERSION
84
+
85
+ def test_parses_a_label_on_real_calldata(self):
86
+ transfer = "0xa9059cbb" + "0" * 24 + "ca" * 20 + "0" * 58 + "4c4b40"
87
+ assert parse_label(transfer + self.label[2:]).agent_id == ID
88
+
89
+ @pytest.mark.parametrize(
90
+ "value",
91
+ [None, "", "0x", "not hex", "0xzz", "0xa9059cbb", "0x50554c53", "0x" + "00" * 19 + "50554c53"],
92
+ )
93
+ def test_returns_none_never_raises_for_anything_that_is_not_a_label(self, value):
94
+ assert parse_label(value) is None
95
+
96
+ def test_returns_none_for_an_unknown_version_rather_than_guessing(self):
97
+ assert parse_label(build_label(ID, 0, None, version=0x02)) is None
98
+
99
+ def test_returns_none_when_a_reserved_flag_bit_is_set(self):
100
+ assert parse_label(f"0x{ID[2:]}0000fc0150554c53") is None
101
+
102
+ def test_is_case_insensitive(self):
103
+ assert parse_label(self.label.upper().replace("0X", "0x")) is not None
104
+
105
+ def test_ignores_odd_length_hex(self):
106
+ assert parse_label("0xabc") is None
107
+
108
+ def test_accepts_bytes(self):
109
+ assert parse_label(bytes.fromhex(self.label[2:])).agent_id == ID
110
+
111
+
112
+ class TestAppendAndStrip:
113
+ label = build_label(ID)
114
+
115
+ def test_appends_to_empty_calldata(self):
116
+ assert append_label("0x", self.label) == self.label
117
+ assert append_label(None, self.label) == self.label
118
+
119
+ def test_never_stacks_two_labels(self):
120
+ once = append_label("0xa9059cbb", self.label)
121
+ assert append_label(once, self.label) == once
122
+
123
+ def test_strips_back_to_the_original(self):
124
+ base = "0xa9059cbb" + "0" * 63 + "1"
125
+ assert strip_label(append_label(base, self.label)) == base
126
+
127
+ def test_leaves_unlabelled_calldata_alone(self):
128
+ assert strip_label("0xa9059cbb") == "0xa9059cbb"
129
+
130
+ def test_has_label_agrees_with_parse_label(self):
131
+ assert has_label(self.label) is True
132
+ assert has_label("0xa9059cbb") is False
133
+
134
+
135
+ class TestDeriveAgentId:
136
+ def test_matches_keccak_of_operator_and_name(self):
137
+ from eth_utils import keccak
138
+
139
+ operator = "0x1111111111111111111111111111111111111111"
140
+ expected = "0x" + keccak(bytes.fromhex(operator[2:]) + b"price-watcher").hex()[:32]
141
+ assert derive_agent_id(operator, "price-watcher") == expected
142
+
143
+ def test_is_stable_and_distinct(self):
144
+ op = "0x2222222222222222222222222222222222222222"
145
+ a = derive_agent_id(op, "alpha")
146
+ b = derive_agent_id(op, "beta")
147
+ c = derive_agent_id("0x3333333333333333333333333333333333333333", "alpha")
148
+ assert len({a, b, c}) == 3
149
+ assert derive_agent_id(op, "alpha") == a
150
+
151
+ def test_rejects_a_non_address_operator(self):
152
+ with pytest.raises(RecensusLabelError, match="20-byte address"):
153
+ derive_agent_id("0xdead", "x")
154
+
155
+
156
+ class TestHelpers:
157
+ def test_flags_round_trip(self):
158
+ assert flags_to_byte(LabelFlags(True, True)) == 3
159
+ assert byte_to_flags(3) == LabelFlags(True, True)
160
+ assert flags_to_byte(None) == 0
161
+
162
+ def test_short_agent_id(self):
163
+ assert short_agent_id(ID) == "0x9f2a0c1e…d7b4a5c3"
164
+
165
+ def test_normalize_accepts_bytes_and_bare_hex(self):
166
+ assert normalize_agent_id(bytes.fromhex(ID[2:])) == ID
167
+ assert normalize_agent_id(ID[2:]) == ID
168
+
169
+
170
+ class TestCollisionBehaviour:
171
+ def test_does_not_claim_calldata_too_short_to_be_a_label(self):
172
+ assert parse_label("0x50554c5350554c53") is None
173
+
174
+ def test_does_claim_24_bytes_ending_in_the_magic(self):
175
+ # RECENSUS-1 §5.1: a label is a claim, never proof. Attribution is what
176
+ # keeps this honest, not the parser.
177
+ assert parse_label("0x" + "11" * 19 + "000150554c53") is not None
@@ -0,0 +1,141 @@
1
+ """
2
+ The two SDKs must produce the same bytes.
3
+
4
+ This runs the TypeScript implementation over a set of vectors and checks the
5
+ Python one agrees, so a change to either side that breaks the other fails here
6
+ rather than on someone's chain.
7
+
8
+ Skipped when Node is not available, because a Python-only environment should
9
+ still be able to run the rest of the suite.
10
+ """
11
+
12
+ import json
13
+ import os
14
+ import shutil
15
+ import subprocess
16
+ from pathlib import Path
17
+
18
+ import pytest
19
+
20
+ from recensus_sdk import LabelFlags, build_label, derive_agent_id, parse_label
21
+
22
+ REPO = Path(__file__).resolve().parents[3]
23
+ SPEC_DIST = REPO / "packages" / "spec" / "dist" / "index.js"
24
+
25
+ VECTORS = [
26
+ {"agentId": "0x9f2a0c1e7b5d4a8f36c20e91d7b4a5c3", "framework": 0x0002, "autonomous": True, "test": False},
27
+ {"agentId": "0x00000000000000000000000000000000", "framework": 0x0000, "autonomous": False, "test": False},
28
+ {"agentId": "0xffffffffffffffffffffffffffffffff", "framework": 0xFFFF, "autonomous": True, "test": True},
29
+ {"agentId": "0x1122334455667788990011223344556f", "framework": 0x0010, "autonomous": False, "test": True},
30
+ {"agentId": "0xabcdef0123456789abcdef0123456789", "framework": 0x0003, "autonomous": False, "test": False},
31
+ ]
32
+
33
+ CALLDATA = [
34
+ "0x",
35
+ "0xa9059cbb",
36
+ "0x095ea7b3" + "0" * 128,
37
+ "0x" + "de" * 200,
38
+ ]
39
+
40
+
41
+ def node_available() -> bool:
42
+ return shutil.which("node") is not None and SPEC_DIST.exists()
43
+
44
+
45
+ pytestmark = pytest.mark.skipif(
46
+ not node_available(),
47
+ reason="needs node and a built packages/spec (pnpm --filter @recensus/spec build)",
48
+ )
49
+
50
+
51
+ def run_typescript(script: str) -> dict:
52
+ result = subprocess.run(
53
+ ["node", "--input-type=module", "-e", script],
54
+ capture_output=True,
55
+ text=True,
56
+ cwd=REPO,
57
+ env={**os.environ, "NODE_NO_WARNINGS": "1"},
58
+ timeout=60,
59
+ )
60
+ if result.returncode != 0:
61
+ pytest.fail(f"the TypeScript side failed:\n{result.stderr}")
62
+ return json.loads(result.stdout)
63
+
64
+
65
+ def test_build_label_matches_typescript():
66
+ script = f"""
67
+ import {{ buildLabel }} from '{SPEC_DIST.as_posix()}';
68
+ const vectors = {json.dumps(VECTORS)};
69
+ console.log(JSON.stringify(vectors.map((v) =>
70
+ buildLabel({{ agentId: v.agentId, framework: v.framework, flags: {{ autonomous: v.autonomous, test: v.test }} }})
71
+ )));
72
+ """
73
+ from_ts = run_typescript(script)
74
+ from_py = [
75
+ build_label(v["agentId"], v["framework"], LabelFlags(autonomous=v["autonomous"], test=v["test"]))
76
+ for v in VECTORS
77
+ ]
78
+ assert from_py == from_ts
79
+
80
+
81
+ def test_parse_label_matches_typescript():
82
+ labels = [
83
+ build_label(v["agentId"], v["framework"], LabelFlags(v["autonomous"], v["test"]))
84
+ for v in VECTORS
85
+ ]
86
+ cases = [data + label[2:] for label in labels for data in CALLDATA] + CALLDATA
87
+
88
+ script = f"""
89
+ import {{ parseLabel }} from '{SPEC_DIST.as_posix()}';
90
+ const cases = {json.dumps(cases)};
91
+ console.log(JSON.stringify(cases.map((c) => {{
92
+ const p = parseLabel(c);
93
+ return p === null ? null : {{ agentId: p.agentId, framework: p.framework, flagsByte: p.flagsByte, version: p.version }};
94
+ }})));
95
+ """
96
+ from_ts = run_typescript(script)
97
+
98
+ from_py = []
99
+ for case in cases:
100
+ parsed = parse_label(case)
101
+ from_py.append(
102
+ None
103
+ if parsed is None
104
+ else {
105
+ "agentId": parsed.agent_id,
106
+ "framework": parsed.framework,
107
+ "flagsByte": parsed.flags_byte,
108
+ "version": parsed.version,
109
+ }
110
+ )
111
+
112
+ assert from_py == from_ts
113
+
114
+
115
+ def test_derive_agent_id_matches_typescript():
116
+ pairs = [
117
+ ("0x1111111111111111111111111111111111111111", "price-watcher"),
118
+ ("0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266", "claude"),
119
+ ("0x0000000000000000000000000000000000000000", ""),
120
+ ("0xabcdefabcdefabcdefabcdefabcdefabcdefabcd", "an agent with spaces and é"),
121
+ ]
122
+
123
+ script = f"""
124
+ import {{ deriveAgentId }} from '{SPEC_DIST.as_posix()}';
125
+ import {{ keccak256 }} from '{(REPO / "node_modules" / "viem" / "_esm" / "index.js").as_posix()}';
126
+ const pairs = {json.dumps(pairs)};
127
+ console.log(JSON.stringify(pairs.map(([op, name]) => deriveAgentId(op, name, (b) => keccak256(b)))));
128
+ """
129
+ from_ts = run_typescript(script)
130
+ from_py = [derive_agent_id(op, name) for op, name in pairs]
131
+ assert from_py == from_ts
132
+
133
+
134
+ def test_reserved_flag_bits_are_rejected_on_both_sides():
135
+ script = f"""
136
+ import {{ parseLabel }} from '{SPEC_DIST.as_posix()}';
137
+ const malformed = '0x' + '11'.repeat(16) + '0000' + 'fc' + '01' + '50554c53';
138
+ console.log(JSON.stringify(parseLabel(malformed)));
139
+ """
140
+ assert run_typescript(script) is None
141
+ assert parse_label("0x" + "11" * 16 + "0000" + "fc" + "01" + "50554c53") is None