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.
- recensus_sdk-1.0.0/.gitignore +44 -0
- recensus_sdk-1.0.0/PKG-INFO +105 -0
- recensus_sdk-1.0.0/README.md +87 -0
- recensus_sdk-1.0.0/pyproject.toml +36 -0
- recensus_sdk-1.0.0/recensus_sdk/__init__.py +45 -0
- recensus_sdk-1.0.0/recensus_sdk/frameworks.py +20 -0
- recensus_sdk-1.0.0/recensus_sdk/label.py +225 -0
- recensus_sdk-1.0.0/recensus_sdk/recensus.py +240 -0
- recensus_sdk-1.0.0/recensus_sdk/safety.py +122 -0
- recensus_sdk-1.0.0/recensus_sdk/sign.py +74 -0
- recensus_sdk-1.0.0/tests/test_label.py +177 -0
- recensus_sdk-1.0.0/tests/test_parity.py +141 -0
|
@@ -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
|