langchain-erc8004
LangChain tools for the ERC-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.
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.
Install
pip install langchain-erc8004
Quick start
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:
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 carriesfiltered_by_client: falseplus 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
# 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=Trueto override, for a registry you control). Redirects are followed by hand, at most 3, with every hop re-checked — a public URL redirecting to169.254.169.254is 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-Lengthis 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
setAgentURIinvalidates 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.
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:
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:
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.
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, nevertime.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
_updateto blankagentWalleton 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:
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 tomsg.senderthrough_safeMint. Your account must answeronERC721Receivedor it cannot hold an agent at all. The plan warns when the sender has code, and — when the account is already deployed — oneeth_callrefuses 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
agentWallettomsg.sender, which for a 4337 account is the account, not the session key or operator that triggered it. No separateset_agent_walletis needed unless you want a different address. parse_registration_receiptfilters logs by the emitting contract, not just the event signature. web3's ownprocess_receiptmatches topic0 alone, which matters precisely here: a batched UserOp touching several protocols could otherwise have a foreignRegistered(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.
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)
| 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.0and look uninitialised —ownerOf(),name()andsymbol()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:
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:
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:
getSummaryhas 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. Soget_validation_summaryrequiresvalidators, and the unfiltered path islist_all_validationswith a warning, same discipline as §8.2.- A response of 0 is stored identically to no response. The contract keeps a
hasResponseflag butgetValidationStatusdoes not return it, and a fresh request storesresponse = 0. Sohas_responseis three-state:truewhen the record proves an answer,nullwhen the two are genuinely indistinguishable. Never a guess. requestHashis 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 readsgetVersion()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_verifiedistrue,false, ornull—nullmeaning the CID's form (CIDv0Qm…/ 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
pytest # the mocked suite -- no network, no keys
Live tests are opt-in and never run by default (addopts = "-m 'not live'"):
# 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
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:
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
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file langchain_erc8004-0.1.1.tar.gz.
File metadata
- Download URL: langchain_erc8004-0.1.1.tar.gz
- Upload date:
- Size: 134.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
aa291319d2fbc0456d425370af5d6836e84c32fb70bcca301d42c660818b06d7
|
|
| MD5 |
fc6076fe089c35a0485597d154011417
|
|
| BLAKE2b-256 |
3cb30212b09bcf0a211f0e219ca93c704353ab271065fc5d7e349e5ea5380841
|
File details
Details for the file langchain_erc8004-0.1.1-py3-none-any.whl.
File metadata
- Download URL: langchain_erc8004-0.1.1-py3-none-any.whl
- Upload date:
- Size: 114.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e61606022d2c11518669a641fb6373508ecabcfb5b399762dd671c1c6619d7cb
|
|
| MD5 |
21257edd31e2da18b6c31b9608fed90a
|
|
| BLAKE2b-256 |
bd9327142b3de011fb331b32d3e8162a225efbe1d50c88d9aee05a1b0fd7e3a8
|