Last updated 2026-05-05
Read ERC-8004 onchain with TypeScript and viem
Reading ERC-8004 onchain with viem is mostly a matter of pointing a PublicClient at an RPC and calling getLogs against the right registry addresses. This walkthrough does that on any EVM chain with viem v2 and a Quicknode endpoint. The walkthrough starts with a client, lists recent agents, fetches one agent’s metadata off IPFS, and reads its feedback. Roughly 200 lines, no wallet needed.
Prerequisites
Start here. This is the recommended first tutorial. The TypeScript client and project structure set up below are reused by register-agent, submit-feedback, and become-a-validator.
- Node 20+ and a TypeScript project (or
tsxfor one-off runs) - An RPC URL (sign up at Quicknode for the chain you want to read from)
- The Identity / Reputation / Validation Registry addresses for that chain (see /docs/contracts)
- Patience for IPFS: agent metadata is content-addressed and fetched off-chain, so the first fetch from a cold gateway can take a couple of seconds
- (Optional) A Quicknode IPFS gateway URL for fetching agent metadata, set as
QUICKNODE_IPFS_GATEWAY_URL. Falls back toipfs.ioif not set. Find your dedicated gateway URL in the Quicknode IPFS dashboard.
Install
npm install viem@^2.21.0
viem is the only runtime dep needed for this tutorial. The pin at major 2 is intentional: the v1 → v2 migration changed the public API enough that older snippets won’t compile against v2, and latest would mean a future v3 silently breaks everything below.
Connect to the chain
createPublicClient is viem’s read-only client. You give it a chain (one of viem’s named exports) and a transport (here, plain HTTP pointed at your Quicknode endpoint). One client handles every read below.
To switch chains, swap the chain import. viem ships named exports for mainnet, base, bsc, avalanche, mantle, and most other EVM chains. The RPC URL is the only other thing that has to change.
// src/client.ts
import { createPublicClient, http } from "viem";
import { mainnet, base, bsc, avalanche, mantle } from "viem/chains";
const QUICKNODE_RPC_URL = process.env.QUICKNODE_RPC_URL!; // e.g. https://abc.quiknode.pro/xyz/
export const client = createPublicClient({
chain: mainnet, // swap for base / bsc / avalanche / mantle
transport: http(QUICKNODE_RPC_URL),
});
List recent agent registrations
Every agent registration emits a Registered(uint256 indexed agentId, string agentURI, address indexed owner) event from the Identity Registry. To list agents, call getLogs with that event signature and the registry address.
For Ethereum mainnet, the Identity Registry is at 0x8004A169FB4a3325136EB29fA0ceB6D2e539a432
. CREATE2 means it’s byte-identical on every reference-deployment chain. The other addresses live in /docs/contracts.
The fromBlock choice is the main knob. Use the deploy block (24339871 on Ethereum mainnet) to backfill the whole registry. Use something like head - 10_000n for a “what’s new lately” view (~5–6 hours of Base history, or ~33 hours on Ethereum mainnet). Going earlier than the deploy block is wasted RPC budget.
viem decodes indexed topics and the non-indexed event data, so agentId, owner, and agentURI all come out of log.args:
// src/list-agents.ts
import { parseAbiItem } from "viem";
import { client } from "./client.js";
const IDENTITY_REGISTRY = "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432";
const event = parseAbiItem(
"event Registered(uint256 indexed agentId, string agentURI, address indexed owner)"
);
const head = await client.getBlockNumber();
const logs = await client.getLogs({
address: IDENTITY_REGISTRY,
event,
fromBlock: head - 10_000n, // last ~10k blocks (Quicknode eth_getLogs cap); use 24339871n to backfill from deploy
toBlock: "latest",
});
for (const log of logs) {
console.log(log.args.agentId, "→", log.args.agentURI, "(owner", log.args.owner, ")");
}
Backfilling all historical registrations? For deep history, use Quicknode Streams: filtered event data pushed to your storage with built-in backfill.
Fetch one agent’s metadata
The agentURI field is typically ipfs://<cid>. Browsers and Node can’t fetch ipfs:// directly, so rewrite it to your Quicknode IPFS gateway first, then fall back to a public gateway like https://ipfs.io/ipfs/<cid> if needed. In production, set a short AbortSignal.timeout(...) on the fetch and fall back to a second gateway on timeout; gateways flap independently, so a retry on a different host fixes most transient failures.
The metadata JSON shape is name, description, image, and endpoints. The full schema is documented on the Identity Registry page.
// src/fetch-metadata.ts
async function fetchAgentMetadata(agentURI: string) {
const quicknodeIpfsGateway = (process.env.QUICKNODE_IPFS_GATEWAY_URL ?? "https://ipfs.io/ipfs/")
.replace(/\/?$/, "/");
const url = agentURI.startsWith("ipfs://")
? agentURI.replace("ipfs://", quicknodeIpfsGateway)
: agentURI;
const res = await fetch(url);
if (!res.ok) throw new Error(`metadata fetch failed: ${res.status}`);
return res.json() as Promise<{
name: string;
description: string;
image?: string;
endpoints?: Array<{ type: string; url: string }>;
}>;
}
const agentURI = process.argv[2];
if (!agentURI) {
console.error("Usage: tsx src/fetch-metadata.ts <agentURI>");
process.exit(1);
}
const metadata = await fetchAgentMetadata(agentURI);
console.log(JSON.stringify(metadata, null, 2));
Read feedback for an agent
Feedback events come from the Reputation Registry. The signature is long but the shape isn’t bad. agentId and clientAddress are indexed. The third indexed slot is keccak256(tag1), which is handy if you want to prefilter by tag without scanning every row. The numeric value is value (signed int128) divided by 10 ** valueDecimals; this Explorer only includes rows whose normalized value lands in the 0-100 score range when calculating feedback_score.
The Reputation Registry on Ethereum mainnet is at 0x8004BAa17C55a88189AE136b182e5fdA19dE9b63
. Testnet deployments may use different addresses. Confirm at /docs/contracts before running. viem’s args filter narrows the query to a specific agent without you having to hash the topic by hand:
// src/read-feedback.ts
import { parseAbiItem } from "viem";
import { client } from "./client.js";
const REPUTATION_REGISTRY = "0x8004BAa17C55a88189AE136b182e5fdA19dE9b63";
const event = parseAbiItem(
"event NewFeedback(uint256 indexed agentId, address indexed clientAddress, uint64 feedbackIndex, int128 value, uint8 valueDecimals, string indexed indexedTag1, string tag1, string tag2, string endpoint, string feedbackURI, bytes32 feedbackHash)"
);
async function listFeedback(agentId: bigint, fromBlock: bigint, toBlock: bigint) {
return client.getLogs({
address: REPUTATION_REGISTRY,
event,
args: { agentId },
// To prefilter by tag, also pass: indexedTag1: "quality"
// viem hashes the string into a bytes32 topic for you.
fromBlock,
toBlock,
});
}
const agentIdArg = process.argv[2];
if (!agentIdArg) {
console.error("Usage: tsx src/read-feedback.ts <agentId> [fromBlock]");
process.exit(1);
}
const agentId = BigInt(agentIdArg);
const head = await client.getBlockNumber();
const fromBlock = process.argv[3] ? BigInt(process.argv[3]) : head - 10_000n;
const toBlock = fromBlock + 9_999n < head ? fromBlock + 9_999n : head;
const logs = await listFeedback(agentId, fromBlock, toBlock);
if (logs.length === 0) {
console.log("No feedback found in this block range.");
} else {
for (const log of logs) {
const score = Number(log.args.value) / 10 ** Number(log.args.valueDecimals);
console.log(`[${log.args.feedbackIndex}] score=${score} tag=${log.args.tag1} client=${log.args.clientAddress}`);
}
}
endpoint and feedbackURI are optional; submitters may provide a score and tag alone. Filter out empty feedbackURI rows if you’re rendering a review list.
Each feedback row’s normalized value is Number(value) / 10 ** valueDecimals. Because value is int128 (signed), a non-conforming submission can carry a negative number or a metric that is not score-like at all. Clamp for display, and exclude out-of-range values before aggregating. The Reputation Registry article explains how non-revoked rows roll into the explorer’s composite score.
One thing to handle on the read side: clients can pull a feedback record back by emitting FeedbackRevoked(uint256 indexed agentId, address indexed clientAddress, uint64 indexed feedbackIndex). Any indexer that aggregates feedback has to subtract those revocations, otherwise the score lies. For a one-off read it’s fine to ignore; for a real index, listen to both events and reconcile by (agentId, clientAddress, feedbackIndex).
Pagination and archive depth
Two things tend to bite once you move from prototype to production reads.
- Block-range cap and retries. RPC providers cap how wide a window a single
getLogscall can sweep. Quicknode’s cap is generous but if you hit “block range too wide”, chunkfromBlock/toBlockinto smaller windows and concatenate. While you’re there, wrap each call in a 3-attempt retry with exponential backoff (RPCs flap and viem doesn’t retry by default). For deep historical backfills, Quicknode Streams is the better path: filtered event data pushed to your storage with built-in backfill and no range cap. - Archive node. Backfilling from the deploy block needs archive depth. Quicknode’s paid tiers include archive (see /quicknode). Without archive history you’re stuck reading from the chain head forward, so a brand-new index can never catch up to events older than your provider’s retention window.
Run it
# List recent agent registrations
QUICKNODE_RPC_URL=https://... npx tsx src/list-agents.ts
# Fetch metadata for one agent (pass an agentURI from the list above)
QUICKNODE_RPC_URL=https://... QUICKNODE_IPFS_GATEWAY_URL=https://... npx tsx src/fetch-metadata.ts ipfs://QmYourCID
# Read feedback for an agent (replace 32172 with an agentId from list-agents)
QUICKNODE_RPC_URL=https://... npx tsx src/read-feedback.ts 32172
Next steps
- What is ERC-8004?: conceptual overview
- Register your first agent: the write path
- Get RPC for ERC-8004: Quicknode signup
- Contract addresses
- The Reputation formula: /reputation-v1
- viem docs: https://viem.sh
- The canonical EIP: https://eips.ethereum.org/EIPS/eip-8004
FAQ
Why viem instead of ethers.js?
viem v2 is the current standard for TypeScript Ethereum clients: smaller bundle, first-class type inference from ABIs, and a clean tree-shakable API. ethers.js still works, but viem is what most new projects pick.
Do I need an archive node?
For full-history reads (every Registered event since deploy), yes; you need archive depth to call eth_getLogs from the deploy block. Quicknode’s paid tiers include archive. The free tier indexes the most recent few thousand blocks, which is fine for reading current state but won’t backfill an index from genesis.
How fast is getLogs?
Depends on your RPC provider’s block-range cap and the density of events. ERC-8004 events are sparse on most chains today (the standard is new), so a single getLogs call from deploy block to head usually returns in under a second on Quicknode mainnet RPCs. Once volume picks up, paginate by block range.
Can I use viem in a browser?
Yes. Everything in this tutorial works the same in a browser as in Node; switch the http() transport for window.ethereum if you need wallet signing, but reads don’t require a wallet. Be careful with putting your RPC URL in client-side code; rotate keys you’ve exposed.
Where do agent metadata files live?
Most agents host metadata on IPFS. Quicknode IPFS can pin and serve those files through a dedicated gateway; public gateways like ipfs.io work for fallback fetching. Some agents host on Arweave or HTTPS. The agentURI field in the Registered event tells you the scheme; use a small fetcher that supports ipfs:// gateway rewriting if you don’t already have one.