recensus-sdk 1.0.0__py3-none-any.whl

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,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
recensus_sdk/label.py ADDED
@@ -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"]
recensus_sdk/safety.py ADDED
@@ -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)
recensus_sdk/sign.py ADDED
@@ -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,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,9 @@
1
+ recensus_sdk/__init__.py,sha256=nLdhK3B6Xv_ZZoLxwm4_pNcgisupBNIGSfBjTkdhs30,1873
2
+ recensus_sdk/frameworks.py,sha256=t8TFq3DGYmXAcXw7Yho6YBUYwhWJ-lsqJcYw6e3RHGs,528
3
+ recensus_sdk/label.py,sha256=Ydw0LPj3MNWU4t_aglaZX1BqFujB8ayjCwWOuv1I02o,6823
4
+ recensus_sdk/recensus.py,sha256=c9Ur8XpS2a1bzSXe3HWzjInZpZ2Ct4SOZOzLaWBJyCs,9464
5
+ recensus_sdk/safety.py,sha256=jWGXeahrGL4WBwTjp37zYoQBBEaxYw17gjF2TJuklU0,4236
6
+ recensus_sdk/sign.py,sha256=zdeaGG8-htGYp4Ay35mjYHa6XujffLWxopkHERBv8r0,2190
7
+ recensus_sdk-1.0.0.dist-info/METADATA,sha256=VWhBjXN1z5mg20owuIkunTSNIHZUXRzCIA4TQYbiDfg,3430
8
+ recensus_sdk-1.0.0.dist-info/WHEEL,sha256=THafob7ofN-NsuMN7Mg4qZyHaQI7KkD-QlcQatYhXPo,87
9
+ recensus_sdk-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.3
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any