Metadata-Version: 2.4
Name: langchain-erc8004
Version: 0.1.0
Summary: LangChain tools for the ERC-8004 agent identity, reputation, and validation registries: resolve agents, read attributed trust signals, and build registry writes as execution plans.
Author-email: conrad.japhet@gmail.com
License-Expression: MIT
Project-URL: Repository, https://github.com/Conrad-sudo/langchain-erc8004
Project-URL: Issues, https://github.com/Conrad-sudo/langchain-erc8004/issues
Keywords: langchain,erc8004,agent,identity,reputation,web3,ethereum,evm,a2a,mcp,trustless-agents,erc4337,smart-wallet
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: web3>=7.0
Requires-Dist: langchain-core>=0.3
Requires-Dist: requests>=2.31
Requires-Dist: pydantic>=2.0
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Requires-Dist: ruff==0.16.1; extra == "dev"
Requires-Dist: pyright==1.1.411; extra == "dev"
Dynamic: license-file

# langchain-erc8004

LangChain tools for the [ERC-8004](https://eips.ethereum.org/EIPS/eip-8004) "Trustless
Agents" registries — agent **identity**, **reputation**, and **validation** — usable by an
EOA or by any smart-contract wallet (ERC-4337, ERC-7579, ERC-6900, Safe, or bespoke).

> **Status: 0.1.0.** Complete across identity, reputation and validation — 29 tools by default,
> 36 with a Validation Registry supplied. Read tools, write tools, registration-file resolution
> with verified on-chain↔off-chain joins, and client-side aggregation. Exercised against Base
> mainnet and broadcast against Ethereum Sepolia; see [Running the tests](#running-the-tests).

## What it is

ERC-8004 is deliberately half on-chain, half off-chain. The registry stores an agent's owner
and a URI; the URI points at a JSON registration file that claims a name, a description, and
service endpoints. This package covers both halves, and — critically — **verifies the join
between them**.

```
on-chain  IdentityRegistry.tokenURI(agentId)  ──►  agentURI
                                                     │  resolve (ipfs / https / data)
off-chain                                            ▼
                                            registration file (JSON)
                                              name, description, services[],
                                              registrations[], supportedTrust[]
```

Nothing stops an agent pointing its `agentURI` at someone else's file, so a resolved file is
only trustworthy if it names back the agent you actually queried. `get_agent` checks that and
reports it at the top level, with a reason rather than a bare boolean:

| `verification_reason` | Meaning |
|---|---|
| `verified` | The file claims this exact agent. Trustworthy as a self-description. |
| `agentid-mismatch` | The file describes a **different** agent. Its claims are not this agent's. |
| `no-entry-for-this-chain` | The file makes no claim about this registry at all. |
| `chain-ok-agentid-null` | Names this registry but no id, so it never claims to be this agent. Common and weak. |
| `no-registrations-field` | Not a spec-shaped registration file. |
| `no-uri` / `not-resolved` | Nothing to check, or unreachable. |

The reason matters because on live chains most agents do **not** strictly verify — a bare
`false` would be true so often that it gets ignored. Both halves of the check must match on the
*same* `registrations[]` entry; checking them independently is a real vulnerability, and a file
that exploits it is live on Base today.

## What it is not

- **Not a signer.** It never holds a key, signs, or broadcasts. Write tools return an ordered
  execution *plan*; you submit it.
- **Not an agent search engine.** "Find me a translation agent" needs an indexer or subgraph,
  not an RPC. Faking it with log scans would be slow and incomplete.
- **Not a reputation oracle.** There is no `get_agent_score()`, by design — see
  [Trust and sybil resistance](#trust-and-sybil-resistance).

## Install

```bash
pip install langchain-erc8004
```

## Quick start

```python
from langchain_erc8004 import ERC8004Toolkit

# Base mainnet, where the agent population actually lives.
toolkit = ERC8004Toolkit.for_chain(8453, rpc_url="https://your-endpoint")

# Confirm what you are bound to: addresses, versions, and whether the
# reputation registry is actually paired to this identity registry.
print(toolkit.registry_info())

tools = toolkit.get_tools()  # wire into any LangChain agent
```

Registry addresses resolve automatically on all 48 supported chains. On any other chain, pass
them explicitly — ERC-8004 reuses the same pair everywhere, so an unlisted chain usually just
means this table has not caught up:

```python
ERC8004Toolkit(
    rpc_url="https://your-endpoint",
    identity_registry="0x8004A169FB4a3325136EB29fA0ceB6D2e539a432",
    reputation_registry="0x8004BAa17C55a88189AE136b182e5fdA19dE9b63",
)
```

## Trust and sybil resistance

ERC-8004's own Security Considerations are explicit that sybil inflation is expected, and that
the intended mitigation is **filtering by reviewer**. The contract enforces half of this:
`getSummary` reverts without a client list. `readAllFeedback` does *not* — passing an empty
list silently falls back to every client that ever left feedback.

That asymmetry is a trap, and it shapes this package's API:

- There is **no** tool returning a single unattributed trust score.
- Every feedback read returns the **reviewer address per entry**, always.
- The unfiltered path is named for what it is (`list_all_feedback`) and its result carries
  `filtered_by_client: false` plus an explicit sybil warning.
- `client_allowlist=` on the toolkit becomes the default reviewer set for every read.
- Duplicate reviewer addresses are removed before the call. The registry iterates the array it
  is given without deduplicating it, so `[A, A, A]` would weight A's feedback three times —
  the same effect the reviewer filter exists to prevent, reintroduced by a caller's mistake.

An agent's self-description is also attacker-controlled input flowing into a model's context.
Registration-file content is marked as untrusted where it is returned.

## Reading reputation

```python
# 1. Who has reviewed this agent? You cannot filter by reviewer until you know.
tools["get_feedback_clients"].invoke({"agent": "1"})

# 2. Read only the reviewers you have an independent reason to trust.
tools["get_agent_feedback"].invoke({
    "agent": "1",
    "clients": ["0x397558E5...", "0x718BD246..."],
    "tag1": "starred",          # pass this whenever you intend to compare values
})
```

**Tags carry the scale, and the scales are unrelated.** `starred` is 0–100, `responseTime` is
milliseconds, `uptime` is a percentage with two decimals. This is not hypothetical: on Base
mainnet, agent 100's feedback spans seven different `tag1` values, so the registry's own
untagged average over its reviewers is `53` — a number produced by averaging `unreliable: 5`
with `trust-score: 71`. Filtered to `tag1="trust-score"`, the same reviewers give `71`. Any
result mixing tags says so in its `warnings`, and `distinct_tags` lists what was mixed.

**`get_feedback_summary` vs `aggregate_feedback`** return different averages for the same agent,
by design:

| | `get_feedback_summary` | `aggregate_feedback` |
|---|---|---|
| Computed by | the contract's `getSummary` | Python, `Decimal` throughout |
| Precision | truncated to the most common `valueDecimals` in the set — 87.6 reports as **87** | exact |
| Gives you | count and one value | count, mean, median, min, max, stddev, per-tag breakdown |
| Use when | you need the number an on-chain consumer sees | you are showing a human or ranking agents |

`aggregate_feedback` **withholds the overall average when the entries span several tags**, and
returns the per-tag breakdown instead. On Base mainnet, agent 1's 39 entries carry sixteen
different `tag1` values — `creditScore: 513` sits beside `identityCount: 0` and `custom: 0.5`.
The registry's `getSummary` averages them into `81`; there is no sense in which that number
describes the agent. Pass `tag1` to focus on one scale, or `allow_mixed_tags=True` if you know
the tags in play are comparable.

Where a filter does make the number meaningful, the precision is the point. Agent 100 filtered
to `tag1="trust-score"`:

```
PRECISE   mean=71.33  median=71.5  min=70  max=72  stdev=0.75
ON-CHAIN  71
```

...and the result warns that all six of those entries came from a single address, so `71.33` is
one reviewer's opinion repeated, not a consensus. That warning is the kind of thing a bare score
can never carry.

Every number is returned as a **string** computed with `decimal.Decimal` — sums are accumulated
as Python integers at a common scale, so no precision context can round them, and `min`, `max`
and `median` are exact. Only `mean` and `stdev` are rounded, half-even, to a `precision` the
result states.

Above `feedback_chunk_size` reviewers the summary is recomputed locally instead of called,
because `getSummary` cannot be split and merged — an average of already-truncated averages is
not the average. The local path replays the contract's arithmetic exactly, including its
truncation toward zero and its lowest-wins tie-break on modal decimals, so the number is the
same one; a `source` field says which path ran. Verified against Base mainnet agent 1: 39
entries across 20 reviewers, `81` from both paths.

The read views are unbounded `O(clients × feedback)` loops inside a gas-metered `eth_call`, so
wide queries are deduped, capped at `max_clients`, and chunked. A list wider than the cap fails
locally with an explanation rather than timing out at the node.

## Registration files and how they are resolved

The off-chain half. `tokenURI(agentId)` returns a locator; this package fetches it, parses it,
and checks whether the file claims the agent back. Four schemes are handled, and the difference
between them is not cosmetic:

| Scheme | Fetched? | Content verifiable? |
|---|---|---|
| `data:` | No — the bytes are in the URI | No separate hash to check them against (`null`) |
| *no scheme* | No — some agents store the JSON itself in `tokenURI` | Same (`null`) |
| `ipfs://bafkrei…` (CIDv1, raw) | Via gateway | **Yes** — the CID is a sha2-256 of the bytes |
| `ipfs://Qm…` (CIDv0, dag-pb) | Via gateway | No — the hash covers a UnixFS wrapper (`null`) |
| `https://` | Yes | No |
| `http://` | Only with `allow_http=True` | No |

`data:` handles `;base64` and, because roughly 40% of BSC's data URIs use it, `enc=gzip`. Gzip
is detected from the magic bytes as well as the header, since encoders omit the declaration.

**CID verification is a real check, not a formality.** A gateway is an untrusted intermediary. If
the bytes it returns do not hash to the CID that was asked for, they are refused rather than
parsed, and the next gateway is tried — the bad bytes are never used. `content_verified` is
three-state: `true`, `false`, or `null` where the CID's form puts it out of reach. "Not checked"
is never reported as "checked and fine".

### The security model

Registration files are written by the party being evaluated, fetched from a host that party
chose, and their contents flow into a model's context. Every default assumes that:

- **SSRF-hardened.** Private, loopback and link-local addresses are refused
  (`allow_private_hosts=True` to override, for a registry you control). Redirects are followed by
  hand, at most 3, with every hop re-checked — a public URL redirecting to `169.254.169.254` is
  caught at the second hop, not the first.
- **Plain `http://` is refused by default.** This file drives trust decisions and http lets
  anyone on the path rewrite it.
- **Size-capped at 1 MB, enforced during the stream** — `Content-Length` is attacker-supplied and
  may be absent — **and again after decompression**, since a few hundred compressed bytes can
  expand to gigabytes.
- **Time-capped.** 5s per request, and a 30s budget for the whole resolution including every
  gateway fallback, so one slow endpoint cannot stall an agent loop.
- **Cached** for 300s on success and 30s on failure, keyed by URI — so `setAgentURI` invalidates
  naturally, and a dead gateway is not re-dialled on every call in a loop.
- **Marked untrusted.** Anything returned from one of these files carries an explicit
  untrusted-content marker, because an agent's self-description being read by another agent
  deciding whether to trust it is this package's most likely prompt-injection vector.

Turn fetching off entirely with `resolve_uris=False`. `data:` URIs and inline JSON still resolve
— they need no network — and everything else reports *why* it was not fetched rather than
failing silently.

```python
ERC8004Toolkit(
    rpc_url=...,
    resolve_uris=False,        # no outbound requests at all
    ipfs_gateways=("https://your-gateway/ipfs/",),
    max_file_bytes=250_000,
    resolution_budget=10.0,
)
```

## Writing

Write tools return an **execution plan**, never a broadcast. This toolkit holds no key, signs
nothing, and sends nothing:

```python
toolkit = ERC8004Toolkit(rpc_url=..., from_address="0xYourAddress")
plan = tools["give_rating"].invoke({"agent": "412", "score": 90})

plan["calls"]         # [{to, value, data, role, description}] — any account type
plan["transactions"]  # the same, rendered as signable EOA txs (tx_mode="eoa")
plan["gas_estimated"] # per transaction: live estimate, or the static table?
plan["summary"]       # what it does, in words, plus warnings
```

A transaction dict holds transaction fields and nothing else, so it signs
exactly as returned — `acct.sign_transaction(plan["transactions"][0])`, no
preparation step. Anything else would break the first thing an EOA consumer
does: `eth_account` validates its input and rejects an unrecognised key with
`TypeError: Unknown kwargs`. Whether each gas limit came from a live estimate
or the static fallback is a real question, so it is answered in
`plan["gas_estimated"]`, index for index, rather than smuggled into the dict.

`(to, value, data)` is the last point at which every account type still agrees. An EOA
transaction is that plus nonce/gas/fees; an ERC-7579 `Execution` is exactly that; an ERC-4337
UserOp wraps a batch of them. Pass `tx_mode="calls"` for a smart-contract wallet and no
nonce/gas/fee RPC calls are made at all — a 4337 nonce is not an EOA transaction count.

**`give_rating` is the one to reach for**: a whole number 0–100 under the `starred` tag, which
is the convention other readers expect. `give_feedback` is the full form, for fractional values,
custom scales, or an attached document.

Every write validates locally and then preflights against the chain, so a plan that is certain
to revert is never built. In `tx_mode="eoa"` the gas estimate is that preflight. In
`tx_mode="calls"` there is no gas estimate, so one deliberate `eth_call` takes its place —
made only when the sender already has code, since simulating against a counterfactual 4337
account that is deployed by the same UserOp would pass and then revert. Still no nonce, fee or
`eth_estimateGas` request: those are the ones that are wrong for a smart account, not
simulation itself.

That matters most for ERC-721. An agent **is** an NFT, so `register_agent` and `transfer_agent`
go through `_safeMint`/`safeTransferFrom`, which call `onERC721Received` on any recipient with
code and revert with `ERC721InvalidReceiver` unless it answers. Plenty of smart accounts do not
implement it, and nothing in the calldata hints at that — so the plan says so in words as well
as failing the simulation. A reverted preflight names the error rather than quoting it:
`Register a new agent to 0x… would revert: the contract reverted with
ERC721InvalidReceiver(address), arguments 0x…`.

In particular, **an agent cannot rate itself** (§8.3) — `giveFeedback`
reverts for the agent's owner *and* for any ERC-721 operator it has approved, so
`isAuthorizedOrOwner` is checked before any calldata exists. Verified against Base mainnet:

```
stranger -> give_rating(1, 90)  : OK       gas 219,727 (live estimate)
owner    -> give_rating(1, 100) : REFUSED  "owns or operates agent 1 … rejects self-feedback"
agent 99999999                  : REFUSED  "is not registered on Base (chain 8453)"
```

The same pattern covers the rest: `value_decimals` above 18, `|value|` above 1e38, a score
outside 0–100, index 0 (feedback indexes are 1-based), revoking an entry that is already
revoked, revoking someone else's entry, an empty response URI, or a `feedback_hash` with no URI
to hash. Each is a contract `require` restated as a sentence one RPC round trip earlier.

Two things the summary says that the calldata cannot: feedback is **permanent** — revoking marks
it revoked, it is never removed — and **anyone may respond to anyone's feedback**, since the
registry does not check that a responder owns the agent.

### Registering is two steps

`register()` returns the new agentId from the call — and this package never makes the call. So
the id is genuinely not knowable when the plan is built, and predicting it from the current
supply is wrong the moment anyone else registers in the same block:

```python
plan = tools["register_agent"].invoke({"agent_uri": "ipfs://bafkrei..."})
plan["summary"]["agent_id"]     # None — by design

# ... you sign and submit plan["transactions"][0], then:
receipt = w3.eth.wait_for_transaction_receipt(tx_hash)
tools["parse_registration_receipt"].invoke({"receipt": dict(receipt)})
# {"agent_id": 4242, "agent_uri": "ipfs://bafkrei...", "owner": "0x…"}
```

Receipt parsing filters logs by the emitting contract, not just the event signature. web3's own
`process_receipt` matches on topic0 alone, so without that filter any contract emitting a
`Registered(uint256,string,address)` log in the same transaction would be read as a
registration — which matters precisely because the receipts that reach this function are batched
ERC-4337 UserOps touching several protocols at once.

Registration also sets the agent's `agentWallet` to `msg.sender` silently. For a smart-contract
wallet that is the account, not the key that triggered it. The plan's summary says so, because a
caller who does not know will follow it with a redundant wallet call.

### Binding an agent wallet takes two parties

The owner sends the transaction; the **wallet being bound signs** a message consenting to it.
Without that split, anyone could name someone else's address as their agent's wallet.

```python
prepared = tools["build_agent_wallet_typed_data"].invoke({"agent": "412", "new_wallet": "0xWallet"})
prepared["signer"]              # 0xWallet — NOT the owner
prepared["expires_in_seconds"]  # 240

signature = ...                 # 0xWallet signs prepared["typed_data"]

tools["set_agent_wallet"].invoke({
    "agent": "412", "new_wallet": "0xWallet",
    "deadline": prepared["deadline"],   # the same one — it is inside the signed struct
    "signature": signature,
})
```

Signing with the **owner's** key is the mistake this surface exists to catch. On-chain it
surfaces as `"invalid wallet sig"` after the fee is spent; here the signature is recovered
locally first and the error names which party got it wrong. Verified live on Base — a correct
signature simulates successfully against the deployed contract (`eth_estimateGas` succeeds,
meaning it recomputed our EIP-712 digest and recovered our signer), and a wrong one is refused
before any RPC call:

```
CORRECT SIGNER -> eth_estimateGas SUCCEEDED: gas=63,508
WRONG SIGNER   -> "This signature recovers to 0x8bF2…, which is neither the wallet being bound…"
```

Three things to know:

- **The deadline is at most five minutes, in chain time.** Both bounds are compared against
  `block.timestamp`, so deadlines here are derived from the chain's latest block, never
  `time.time()` — a few minutes of host clock skew would otherwise produce `"expired"` or
  `"deadline too far"` from a caller who did nothing wrong. Note that a *generous* deadline is
  rejected, which is the opposite of every other expiry you have met.
- **The deadline is part of the signed struct**, so "just extend it" needs a fresh signature.
- **A transfer clears the agent's wallet.** The registry overrides ERC-721's `_update` to blank
  `agentWallet` on any non-mint transfer, deliberately: a wallet the old owner proved control of
  must not carry over. The new owner starts with none.

Smart-contract wallets work too — the registry tries ECDSA and falls back to ERC-1271 — but
note that **"has code" is not the test for one**. An EIP-7702 delegated EOA carries
`0xef0100 ‖ address` and still signs with its own key. That distinction is real: a throwaway
address used to probe this on Base turned out to carry a delegation, and a deliberately wrong
signature was waved through as "might be ERC-1271" until the chain rejected it.

When the wallet does have code, the fallback is not left as a mystery: this package makes the
same `isValidSignature` staticcall the registry will make, against the same digest, and refuses
a signature the wallet itself rejects. Three answers, and they are genuinely different — yes
(built, with a note saying why a non-recovering signature is valid), no (refused, before the
fee), and *no answer at all* when the staticcall reverts, which is the only case that is truly
undecidable from here and the only one that gets a warning instead of a decision. The yes case
includes one that looks wrong and is not: a smart account whose signing key happens to be the
agent's owner really has consented, because a wallet's own rules are what define who speaks
for it.

### Gas fallbacks are measured, not guessed

`estimate_gas` is on by default. The static table is what a call falls back to when estimation
is off, or when a plan's later call depends on an earlier one that is not mined yet. Those
numbers were measured against Base mainnet on 2026-08-19:

| | measured | fallback |
|---|---|---|
| `register` (URI + 2 metadata entries) | 226,219 | 300,000 |
| `set_agent_uri` (replacing a 2.3 KB URI) | 421,102 | 450,000 |
| `set_metadata` (6 bytes → 1 KB) | 58,375 → 809,117 | 350,000 |
| `transfer_agent` (to an EOA) | 77,015 | 90,000 |
| `give_feedback` (plain rating) | 175,782 | 250,000 |
| `set_agent_wallet` (EOA signature) | 63,508 | 130,000 |

`set_agent_uri` is the counterintuitive one: its cost tracks **the larger of the old and new
URI**, because the storage slots touched are however many either occupies. Measured on Sepolia:
216,051 to write ~250 characters onto an agent with none (zero→nonzero slots, 20k each), then
67,864 to replace that with a similar length (nonzero→nonzero, 5k each). And on Base, 406k to
overwrite agent 1's 2.3 KB `data:` URI with 19 characters — a shrink is expensive because every
slot beyond the new length still has to be cleared.

`set_metadata` cannot have a correct constant — it scales with the value — which is why
estimation stays on by default.

## Smart-contract wallets: ERC-4337 and ERC-7579

`plan["calls"]` is the account-agnostic form, and it is what a smart account consumes. Pass
`tx_mode="calls"` and no nonce, gas or fee request is made at all — a 4337 nonce is
`EntryPoint.getNonce(sender, key)`, not an EOA transaction count, and `eth_estimateGas` with
`from` set to a smart account simulates the account calling itself as an EOA, which is not how
the EntryPoint invokes it.

An ERC-7579 `Execution` **is** `(to, value, data)`, so the mapping is mechanical:

```python
toolkit = ERC8004Toolkit(
    rpc_url="https://your-endpoint",
    tx_mode="calls",
    from_address=account.address,   # the smart account, not the key that operates it
)
tools = {t.name: t for t in toolkit.get_tools()}

plan = tools["give_rating"].invoke({"agent": "412", "score": 90, "tag2": "settled-on-time"})

# ERC-7579 execute(bytes32 mode, bytes executionCalldata). A ModeCode's first
# byte is the CallType; 0x01 is batch, and the rest is zero for a plain one.
from eth_abi.abi import encode

BATCH = bytes.fromhex("01") + bytes(31)
executions = [(c["to"], c["value"], Web3.to_bytes(hexstr=c["data"])) for c in plan["calls"]]
call_data = account_contract.encode_abi(
    abi_element_identifier="execute",
    args=[BATCH, encode(["(address,uint256,bytes)[]"], [executions])],
)

user_op = {
    "sender": account.address,
    "nonce": entry_point.functions.getNonce(account.address, 0).call(),
    "callData": call_data,
    # ...gas limits and fees from your bundler's estimate, signature from your validator
}
```

Three things worth knowing before you wire this up:

- **`register()` mints to `msg.sender` through `_safeMint`.** Your account must answer
  `onERC721Received` or it cannot hold an agent at all. The plan warns when the sender has code,
  and — when the account is already deployed — one `eth_call` refuses the plan outright rather
  than letting it revert with the fee spent. An account that is deployed by the same UserOp's
  initCode has no code to simulate against yet, so it is skipped rather than guessed at; that
  case is the warning's job.
- **Registration silently sets `agentWallet` to `msg.sender`**, which for a 4337 account is the
  account, not the session key or operator that triggered it. No separate `set_agent_wallet` is
  needed unless you want a different address.
- **`parse_registration_receipt` filters logs by the emitting contract**, not just the event
  signature. web3's own `process_receipt` matches topic0 alone, which matters precisely here: a
  batched UserOp touching several protocols could otherwise have a foreign
  `Registered(uint256,string,address)` read as yours.

Binding an agent wallet works the same way — the wallet being bound signs, and if it is a
contract, this package makes the same `isValidSignature` staticcall the registry will make and
refuses a signature the wallet rejects. See
[Binding an agent wallet](#binding-an-agent-wallet-takes-two-parties).

## Tool reference

29 tools by default; 36 when a Validation Registry address is supplied. Every write tool returns
a plan and broadcasts nothing.

**Identity — reads**

| Tool | What it answers |
|---|---|
| `get_registry_info` | Addresses, versions, and whether the two registries are actually paired |
| `get_agent` | Everything about one agent, including the verified registration file |
| `agent_exists` | Whether an id is registered, without raising if it is not |
| `get_agent_owner` | The ERC-721 owner |
| `get_agent_uri` | The raw `tokenURI` and its scheme, unresolved |
| `get_agent_wallet` | The operational wallet, or `null` when unset — never the zero address |
| `get_agent_metadata` | One metadata key, as hex and as UTF-8 where it decodes |
| `resolve_registration_file` | Fetch and parse a URI without going through an agent |
| `verify_agent_endpoint` | Whether a service endpoint's domain backs the registration claim |

**Identity — writes (plans)**

| Tool | What it builds |
|---|---|
| `register_agent` | A mint to the sender, optionally with a URI and initial metadata |
| `parse_registration_receipt` | The new agentId, read from a mined receipt — not a plan |
| `set_agent_uri` | Repoint the registration file |
| `set_agent_metadata` | Write one arbitrary key |
| `transfer_agent` | `safeTransferFrom` — gives away full control, and clears `agentWallet` |
| `build_agent_wallet_typed_data` | The EIP-712 message the *wallet* must sign — not a plan |
| `set_agent_wallet` | Bind a wallet that has consented in writing |
| `unset_agent_wallet` | Clear the binding; needs no signature |

**Reputation — reads**

| Tool | What it answers |
|---|---|
| `get_feedback_clients` | Who has reviewed this agent. Start here — you cannot filter until you know |
| `get_agent_feedback` | Attributed entries from named reviewers, chunked and deduped |
| `list_all_feedback` | Every entry, unfiltered, carrying `filtered_by_client: false` and a sybil warning |
| `get_feedback_summary` | The registry's own integer average over a named reviewer set |
| `read_feedback` | One specific entry by reviewer and index |
| `get_last_feedback_index` | How many entries a reviewer has left (1-based) |
| `get_response_count` | How many responses an entry has drawn |
| `aggregate_feedback` | Exact decimal statistics the chain's integer average throws away |

**Reputation — writes (plans)**

| Tool | What it builds |
|---|---|
| `give_rating` | The one to reach for: a whole number 0–100 under the `starred` tag |
| `give_feedback` | The full form — fractional values, custom scales, an attached document |
| `revoke_feedback` | Mark your own entry revoked. It is never removed |
| `append_response` | Respond to anyone's feedback; the registry does not check that you own the agent |

**Validation — opt-in** (present only with `validation_registry=`; see
[the caveat](#the-validation-registry-caveat))

| Tool | What it does |
|---|---|
| `get_validation_status` | One request's verdict, with three-state `has_response` |
| `get_agent_validations` | Every request naming this agent |
| `get_validator_requests` | Every request naming this validator |
| `get_validation_summary` | Average over a **named** validator set — `validators` is required |
| `list_all_validations` | The unfiltered path, with the warning that implies |
| `request_validation` | Ask a named validator for a verdict |
| `submit_validation_response` | Answer as that validator, 0–100, revisable |

## Supported chains

48 deployments — 24 mainnet, 24 testnet — including Ethereum, Base, Arbitrum, Optimism,
Polygon, BSC, Avalanche, Celo, Gnosis, Linea, Scroll, Mantle, Monad, MegaETH, Metis, Soneium,
Taiko, X Layer, Abstract, SKALE Base, GOAT, Hedera, Arc, Billions, Injective, and 0G.

The whole table is verified against live RPCs before each release — most recently on
2026-08-20, when 45 of the 48 answered and all 45 reported `v2.0.0` on both registries, correctly
paired. The other three (Polygon, Polygon Amoy, MegaETH) were unreachable from the release
machine: a 401 and two DNS failures on public endpoints, which says nothing about the addresses.
Two things a reader should know:

- **Soneium mainnet (1868) does not work yet.** Both registries are still on `v1.0.0` and look
  uninitialised — `ownerOf()`, `name()` and `symbol()` all revert. The addresses are right and
  the entry stays so the chain works the moment it is upgraded, but reads fail there today.
  Soneium Minato (testnet) is fine.
- Two chain ids in circulation are wrong and are corrected here: **Taiko Hoodi is 167013**
  (upstream's config says 167012) and **0G Galileo is 16602** (older docs say 16601).

Re-verify any time — and before every release, since these are upgradeable proxies:

```bash
python scripts/verify_networks.py            # all chains
python scripts/verify_networks.py --chain 8453
```

## The Validation Registry caveat

**ERC-8004's Validation Registry has no canonical deployment on any chain.** Every address in
the upstream table is zero. So the seven validation tools are opt-in and appear only when you
supply an address:

```python
ERC8004Toolkit(rpc_url=..., validation_registry="0xYourDeployment")
```

Without it they are not in `get_tools()` at all. Seven tools that can only fail would measurably
degrade which tool a model reaches for, and almost nobody has a deployment to point them at. The
core still raises `RegistryNotConfigured` with the full explanation if called directly.

Where reputation asks "what did clients think", validation asks "what did this specific auditor
conclude" — one request, one named validator, one verdict from 0 to 100 that the validator may
revise. Three things about this registry differ from the reputation one in ways that bite:

- **`getSummary` has no guard on an empty validator list.** The reputation registry at least
  reverts; this one silently averages every validator who answered — and the agent's *own owner*
  chooses who gets asked. So `get_validation_summary` requires `validators`, and the unfiltered
  path is `list_all_validations` with a warning, same discipline as §8.2.
- **A response of 0 is stored identically to no response.** The contract keeps a `hasResponse`
  flag but `getValidationStatus` does not return it, and a fresh request stores `response = 0`.
  So `has_response` is three-state: `true` when the record proves an answer, `null` when the two
  are genuinely indistinguishable. Never a guess.
- **`requestHash` is a caller-chosen primary key, not a content hash.** A second request under
  the same hash is rejected with `"exists"` and the first one keeps the key — so a collision
  means your request silently never happened. One is derived from the agent, validator and URI
  if you don't supply one, and an existing key is refused before you spend a fee.

Ownership is preflighted against the identity registry *that deployment* is wired to, read from
it rather than assumed, since a caller-supplied validation registry need not share the toolkit's.

## Security assumptions

- **The registries are upgradeable.** Both are UUPS proxies behind an owner-controlled
  implementation, currently reporting `2.0.0`. The proxy owner can change behaviour under
  you. The client reads `getVersion()` once at construction and warns on a major-version
  drift rather than failing.
- **Registration files are attacker-controlled.** Fetching is opt-out (`resolve_uris=False`),
  SSRF-hardened, and size- and time-capped (including after decompression).
- **IPFS content is verified against its CID where the CID allows it.** A CID is a hash of the
  content, so a gateway returning different bytes is caught and the response refused rather
  than parsed. `resolution.content_verified` is `true`, `false`, or `null` — `null` meaning the
  CID's form (CIDv0 `Qm…` / dag-pb, which wrap content in UnixFS) puts it out of reach. "Not
  checked" is never reported as "checked and fine". Note this only proves the gateway did not
  tamper; whether the file's *claims* are true is what the join check above is for.
- **Registry pairing is checked, not assumed.** A reputation registry bound to a different
  identity registry returns real-looking results about different agents.

## Roadmap

| Milestone | Scope | Status |
|---|---|---|
| M1 | Skeleton: ABIs, 48-chain table, client, version handshake | done |
| M2 | `plans.py` — execution-plan machinery | done |
| M3 | Identity reads | done |
| M4 | Registration-file resolution + verification | done |
| M5 | Reputation reads: attributed feedback, chunking, on-chain summary | done |
| M6 | Client-side aggregation (`aggregate.py`) | done |
| M7 | Reputation writes: feedback, ratings, revocations, responses | done |
| M8 | Identity writes: register, URI, metadata, transfer | done |
| M9 | Agent wallet: EIP-712 typed data, set/unset | done |
| M10 | Live tests against Base mainnet and Ethereum Sepolia | done |
| M11 | Validation module (opt-in) | done |
| M12 | Docs and release | done |

The plan through M6 was to publish reads only, on the grounds that reads are where the value is.
They still are — but writes turned out to be where the *corrections* were, and several of them
are not recoverable by reading the contracts. A transfer clears `agentWallet` (this package
documented the opposite until a Sepolia broadcast settled it), `register()` mints to `msg.sender`
through `_safeMint`, and agent ids start at 0. Shipping the whole surface is what made those
findings possible.

## Running the tests

```bash
pytest                       # the mocked suite -- no network, no keys
```

Live tests are opt-in and never run by default (`addopts = "-m 'not live'"`):

```bash
# reads only: Base mainnet and Ethereum Sepolia
ERC8004_LIVE=1 pytest -m live

# also broadcasts, on a testnet, from a funded key
ERC8004_LIVE=1 ERC8004_LIVE_WRITES=1 pytest -m live
```

Two gates rather than one on purpose. Reads are safe anywhere; broadcasting spends testnet ether
and leaves permanent records on a public chain, so it should not start happening because a
single variable was set. The write suite refuses to run on any chain that is not a testnet, and
skips if the account has no balance.

Endpoints come from `ERC8004_LIVE_BASE_RPC` / `ERC8004_LIVE_SEPOLIA_RPC`, falling back to
`BASE_RPC_URL` / `SEPOLIA_RPC_URL`. None is bundled — the public endpoints in `networks.py` exist
so `for_chain(8453)` works in one line, not for a test suite to hammer.

The live suite earns its keep. Three bugs reached it that the mocked suite could not have caught:
agent ids starting at 0, `process_receipt` ignoring which contract emitted a log, and EIP-7702
delegated EOAs reading as smart-contract wallets. Each is now pinned by a test.

## Development

```bash
python -m venv .venv && .venv/bin/pip install -e ".[dev]"
.venv/bin/pytest              # unit tests, no network
.venv/bin/pytest -m live      # opt-in, hits Base mainnet
.venv/bin/ruff check . && .venv/bin/ruff format --check .
.venv/bin/pyright
```

The editable install is not optional. `pytest` works without it — `pyproject.toml` sets
`pythonpath = ["."]` — but `pyright` and `scripts/verify_networks.py` both import the package
for real, so without it the first reports sixty unresolved imports and the second reports
`ModuleNotFoundError`. `[tool.pyright]` pins `venvPath`/`venv` at `.venv` so pyright resolves
against the project environment rather than whichever interpreter is first on `PATH`.

ABIs are generated from compiled build artifacts, never hand-written:

```bash
python scripts/generate_abis.py                                # abis/ only
python scripts/generate_abis.py --contracts ../erc-8004-contracts   # + ECDSA errors
```

Two artifact sources are merged because neither is complete alone: the build artifacts in
`abis/` carry `isAuthorizedOrOwner` (which the self-feedback preflight needs), while the
`erc-8004-contracts` checkout carries the `ECDSAInvalidSignature*` errors (which make a bad
`setAgentWallet` signature legible). The generator refuses to write a file that is missing
anything on its whitelist.

## License

MIT
