Skip to main content

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 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

# 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 streamContent-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.

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, 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:

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.

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.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:

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:

  • 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 nullnull 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

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

langchain_erc8004-0.1.0.tar.gz (134.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

langchain_erc8004-0.1.0-py3-none-any.whl (114.4 kB view details)

Uploaded Python 3

File details

Details for the file langchain_erc8004-0.1.0.tar.gz.

File metadata

  • Download URL: langchain_erc8004-0.1.0.tar.gz
  • Upload date:
  • Size: 134.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/7.0.0 CPython/3.12.3

File hashes

Hashes for langchain_erc8004-0.1.0.tar.gz
Algorithm Hash digest
SHA256 cca714415c695d6473f6895122b04478ddb2976250949affc635a2791be59629
MD5 9f005957450785519889f224a2f85db8
BLAKE2b-256 42c4b200c49a68f04c664fe42764fad354924a7c5fdd67d9390dbe33314f21cf

See more details on using hashes here.

File details

Details for the file langchain_erc8004-0.1.0-py3-none-any.whl.

File metadata

File hashes

Hashes for langchain_erc8004-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 db6969c2aecb65cffb177ec7d77496067aaba3a64f1538883b02adb4a09f9a03
MD5 715a12f0af3755700f46aac0da647df4
BLAKE2b-256 7532408f13894812b09773ba706b0f6ef927870850f0f05b36aae3d3e4fc8477

See more details on using hashes here.

Release history Release notifications | RSS feed

0.1.1

2 files

This release

0.1.0 This release

2 files

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page