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.
- recensus_sdk/__init__.py +45 -0
- recensus_sdk/frameworks.py +20 -0
- recensus_sdk/label.py +225 -0
- recensus_sdk/recensus.py +240 -0
- recensus_sdk/safety.py +122 -0
- recensus_sdk/sign.py +74 -0
- recensus_sdk-1.0.0.dist-info/METADATA +105 -0
- recensus_sdk-1.0.0.dist-info/RECORD +9 -0
- recensus_sdk-1.0.0.dist-info/WHEEL +4 -0
recensus_sdk/__init__.py
ADDED
|
@@ -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:]}"
|
recensus_sdk/recensus.py
ADDED
|
@@ -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,,
|