Metadata-Version: 2.5
Name: recensus-sdk
Version: 2.0.0
Summary: Label your agent's transactions on Arc so they show up on Recensus.
Project-URL: Homepage, https://recensus.xyz
Project-URL: Documentation, https://recensus.xyz/docs/get-counted
Project-URL: Specification, https://recensus.xyz/spec
License: MIT
Keywords: ai-agents,arc,evm,recensus,usdc,web3
Requires-Python: >=3.11
Requires-Dist: eth-account>=0.11
Requires-Dist: eth-hash[pycryptodome]>=0.7
Requires-Dist: eth-utils>=4.0
Requires-Dist: web3<8,>=6.20
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == 'dev'
Description-Content-Type: text/markdown

# recensus-sdk

Label your agent's transactions on Arc, Circle's chain where gas is paid in
USDC, so they show up on [Recensus](https://recensus.xyz). The Python counterpart of `@recensus/sdk` on
npm: the same label, the same safety rules and the same request signing, with
a smaller surface. See [What is here](#what-is-here) for exactly what it has.

```bash
pip install recensus-sdk
```

```python
from recensus_sdk import Recensus, derive_agent_id
from web3 import Web3

w3 = Web3(Web3.HTTPProvider("https://rpc.mainnet.arc.io"))

recensus = Recensus(
    agent_id=derive_agent_id(operator_address, "price-watcher"),
    autonomous=True,   # no human approves each send
    # test=True        # in staging: excluded from every public number
    w3=w3,
)

account = recensus.wrap(account)
account.send_transaction({"to": recipient, "value": 10**16})  # 0.01 USDC: native USDC has 18 decimals
```

That is the whole integration. Every send now carries 24 bytes on the end of
its calldata, and the transaction appears on the public scoreboard as your
agent. The label costs 372 gas on a contract call and 930 on a plain transfer
(the EIP-7623 calldata floor); at Arc's 20 gwei base fee that is under
0.00002 USDC.

### An ERC-8004 agent

If your agent is registered in Arc's ERC-8004 Identity Registry, label it with
its registry token ID instead of a derived ID:

```python
recensus = Recensus(erc8004_agent_id=207, autonomous=True, w3=w3)
```

The label then carries the token ID as a big-endian uint128 with the
`erc8004` flag set (label version 2). Give exactly one of `agent_id` and
`erc8004_agent_id`.

## What is here

- `Recensus(agent_id= | erc8004_agent_id=, framework=, autonomous=, test=, w3=, simulate_before_send=, denylist=, deny_selectors=, on_unlabelled=, logger=)`
  - `.tag(calldata)` appends the label, with no checks.
  - `.parse(calldata)` reads a label back, or `None`.
  - `.decide(to, data)` says whether a call's shape may carry the label.
  - `.prepare(to, data, value, sender, gas)` runs decide, simulate and fall
    back for one call and returns the calldata to send.
  - `.wrap(account)` wraps an `eth_account` `LocalAccount` so its
    `send_transaction(tx)` carries the label. It needs `w3=` on the
    constructor, because sending needs a provider.
  - `.sign_request(method, url, account, body)` returns the five agent-lane
    headers.
- `derive_agent_id(operator, name)`, `has_label(calldata)`, and the label
  functions: `build_label`, `parse_label`, `append_label`, `strip_label`,
  `erc8004_agent_id(token_id)` and `erc8004_token_id(label)`.

What the TypeScript SDK has and this one does not:

- **No verify middleware.** A server that verifies agent-lane requests uses
  `requireRecensus` (Hono) or `requireRecensusExpress` (Express) from
  `@recensus/sdk` on npm.
- **No `writeContract` wrapper.** `wrap` covers `send_transaction` on a
  local account only. For a contract call, encode the calldata yourself
  (for example with web3.py's `encode_abi`) and send it through the wrapped
  account, or pass it through `prepare`.
- `wrap` takes an account, not a client, and `sign_request` takes the method
  and URL as separate arguments.

## The label never breaks a transaction

Three layers, in order:

1. **Call shape.** The label goes only where trailing calldata is inert.
   Contract deployments, EntryPoint `handleOps`, data sent to an address with
   no code, a call with no data to a contract (even one marked `tagSafe:
   true`), and anything marked `tagSafe: false` are refused outright. Calldata given as
   `bytes` or `HexBytes` is checked exactly as hex is.
2. **Simulation.** Before sending, the labelled call is simulated. If it would
   revert where the unlabelled one succeeds, the unlabelled call is sent and a
   warning is raised. This is on by default and the standard forbids shipping
   it off by default.
3. **A catch-all.** Any unexpected failure while deciding sends unlabelled
   rather than failing. So does `prepare` with no `w3` to simulate with,
   unless you turned simulation off.

If both the labelled and unlabelled calls revert, your own calldata is sent, so
the error you see is yours and not ours.

## Without web3

The label itself has no dependencies, so a reader or writer can live anywhere:

```python
from recensus_sdk import build_label, parse_label

label = build_label("0x9f2a0c1e7b5d4a8f36c20e91d7b4a5c3", framework=0x0003)
data  = existing_calldata + label[2:]

parse_label(data).agent_id   # '0x9f2a0c1e7b5d4a8f36c20e91d7b4a5c3'
```

`build_label` writes version 2. `parse_label` reads versions 1 and 2, and
returns `None` rather than raising for anything that is not a well-formed
label of a version it knows, including a flag bit its version reserves. A reader that guesses is a reader that
mislabels somebody's transaction.

## The agent lane

```python
from recensus_sdk import canonical_body

url = "https://api.example.com/v1/thing"
body = {"hello": "world"}
headers = recensus.sign_request("POST", url, account, body=body)

# Send the exact bytes that were signed. `requests.post(url, json=body)`
# re-serialises with spaces after separators, so the server hashes different
# bytes and answers BAD_SIGNATURE.
requests.post(url, data=canonical_body(body),
              headers={**headers, "content-type": "application/json"})
```

Five headers an app can verify with `requireRecensus` or
`requireRecensusExpress` from `@recensus/sdk` on npm, so a labelled agent can be given its own rate limits instead of being
throttled like a spam bot.

## Development

```bash
pip install -e '.[dev]'
pytest
```

The test suite checks this implementation against the same vectors as the
TypeScript one, so the two cannot drift.

### The fork suite

`tests/fork/test_conformance.py` is the RECENSUS-1 §4.4 conformance test. For
each call it checks the SDK did not fall back to unlabelled, that the call
carries the label, and that the labelled and unlabelled calls end the same:
same status, logs, return data and balances, and a gas difference of exactly
the label's calldata cost.

An Anvil fork of Arc cannot run a USDC call that moves value: `transfer`,
`transferFrom`, and so a Gateway deposit, revert there even unlabelled, while
they succeed on the chain. So the suite runs in two places.

**On an Anvil fork**, sent through `Recensus.wrap(account)` and mined, each
case twice from one snapshot:

- a native USDC transfer to an EOA, with a derived ID and with an ERC-8004 ID
  (+930 gas);
- USDC `approve` (it moves no value, so it runs on the fork);
- Permit2 `approve`;
- a plain value send to a contract (an EntryPoint), which must go out
  unlabelled and land.

**On real Arc mainnet state**, by `debug_traceCall` with the call tracer at one
block, with the labelled calldata from the SDK's own `prepare()`, whose
simulation runs against mainnet first:

- USDC `transfer` from a real holder (+372 gas; both USDC emitters log it);
- USDC `approve`;
- USDC `transferFrom`, sent as a real spender with a live allowance;
- Circle Gateway `deposit(USDC, 1)` from a real owner with a live allowance to
  the Gateway wallet.

The holders, spender and depositor are found when the suite runs, from recent
USDC `Transfer` and `Approval` logs.

```bash
pkill -f "anvil --fork-url"; FORK_RPC_URL=https://rpc.mainnet.arc.io bash scripts/dev/services.sh up
FORK_RPC=http://127.0.0.1:8546 pytest -m fork tests/fork
```

`MAINNET_RPC` (default `https://rpc.mainnet.arc.io`) serves the `eth_call`s and
log queries, and `TRACE_RPC` (default `https://rpc.drpc.mainnet.arc.io`, the
public endpoint that answers `debug_traceCall`) the traces. No DEX on Arc is
verified, so there are no swap cases.
