diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3ab64d9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,42 @@ +# Changelog + +## 0.1.0-alpha.0 + +Initial alpha release. + +### Identity + +- `registerAgent` — register as an ERC-8004 agent (mints agent NFT) +- `parseRegisterReceipt` — extract agentId from register transaction receipt +- `isRegistered` — check if an address is a registered agent +- `verifyAgentId` — verify that an agentId belongs to a claimed address +- `resolveAgent` — resolve agent by ID (owner, wallet, URI, ownerMismatch flag) +- `getAgentWallet` / `setAgentWallet` / `unsetAgentWallet` — wallet management +- `signAgentWalletConsent` — sign EIP-712 typed data for `setAgentWallet` +- `getMetadata` / `setMetadata` — on-chain key-value metadata +- `parseMetadataSetReceipt` — extract fields from setMetadata transaction receipt +- `setAgentURI` — update agent URI +- `parseURIUpdatedReceipt` — extract fields from setAgentURI transaction receipt +- `getVersion` — read contract version string + +### Reputation + +- `giveFeedback` — submit feedback for an agent +- `parseGiveFeedbackReceipt` — extract fields from giveFeedback transaction receipt +- `revokeFeedback` — revoke previously given feedback +- `parseFeedbackRevokedReceipt` — extract fields from revokeFeedback transaction receipt +- `appendResponse` — append a response to existing feedback +- `parseResponseAppendedReceipt` — extract fields from appendResponse transaction receipt +- `readFeedback` / `readAllFeedback` — read feedback entries (optional `batchSize` for large reviewer sets) +- `getSummary` — aggregated reputation summary +- `getClients` — all reviewer addresses for an agent +- `getLastIndex` — latest feedback index for an agent-client pair +- `getResponseCount` — count responses to a feedback entry +- `getIdentityRegistry` — get linked Identity Registry address +- `getVersion` — read contract version string + +### Infrastructure + +- Registry addresses for 14 chains (Ethereum, Base, Polygon, Arbitrum, Optimism, Avalanche, BSC, Scroll, Linea, Mantle, Gnosis, Celo, Base Sepolia, Ethereum Sepolia) +- Auto-resolve registry address from `client.chain` +- Sub-path exports: `/identity`, `/reputation`, `/abis` diff --git a/README.md b/README.md new file mode 100644 index 0000000..7c25e0c --- /dev/null +++ b/README.md @@ -0,0 +1,134 @@ +# @x402r/erc8004 + +TypeScript SDK for [ERC-8004](https://eips.ethereum.org/EIPS/eip-8004) Identity and Reputation registries. Built on [viem](https://viem.sh). + +## Install + +```bash +npm install @x402r/erc8004 +``` + +## Usage + +### Register an agent + +```ts +import { registerAgent, parseRegisterReceipt } from '@x402r/erc8004/identity' + +const hash = await registerAgent(walletClient, { + agentURI: 'https://example.com/agent.json', +}) + +const receipt = await publicClient.waitForTransactionReceipt({ hash }) +const { agentId } = parseRegisterReceipt(receipt) +``` + +### Verify identity + +```ts +import { verifyAgentId, resolveAgent } from '@x402r/erc8004/identity' + +const valid = await verifyAgentId(publicClient, { + agentId: 42n, + claimedAddress: '0x...', +}) + +const agent = await resolveAgent(publicClient, { agentId: 42n }) +// { agentId, owner, agentWallet, agentURI, ownerMismatch } +``` + +### Give feedback + +```ts +import { giveFeedback, getSummary } from '@x402r/erc8004/reputation' + +await giveFeedback(walletClient, { + agentId: 42n, + value: 85n, + valueDecimals: 0, + tag1: 'service', + tag2: 'quality', +}) + +const summary = await getSummary(publicClient, { + agentId: 42n, + clientAddresses: ['0x...'], + tag1: 'service', + tag2: 'quality', +}) +``` + +## API + +### Identity + +| Function | Description | +|---|---| +| `registerAgent` | Register as an ERC-8004 agent (mints NFT) | +| `parseRegisterReceipt` | Extract `agentId` from register tx receipt | +| `isRegistered` | Check if an address is registered | +| `verifyAgentId` | Verify agentId belongs to a claimed address | +| `resolveAgent` | Resolve agent by ID (owner, wallet, URI) | +| `getAgentWallet` | Get wallet address for an agent | +| `setAgentWallet` | Set verified payment wallet (EIP-712 sig) | +| `signAgentWalletConsent` | Sign EIP-712 consent for `setAgentWallet` | +| `unsetAgentWallet` | Clear agent wallet | +| `getMetadata` | Read on-chain metadata by key | +| `setMetadata` | Write on-chain metadata | +| `parseMetadataSetReceipt` | Extract fields from `setMetadata` tx receipt | +| `setAgentURI` | Update agent URI | +| `parseURIUpdatedReceipt` | Extract fields from `setAgentURI` tx receipt | +| `getVersion` | Read contract version string | + +### Reputation + +| Function | Description | +|---|---| +| `giveFeedback` | Submit feedback for an agent | +| `parseGiveFeedbackReceipt` | Extract fields from `giveFeedback` tx receipt | +| `revokeFeedback` | Revoke previously given feedback | +| `parseFeedbackRevokedReceipt` | Extract fields from `revokeFeedback` tx receipt | +| `appendResponse` | Append a response to feedback | +| `parseResponseAppendedReceipt` | Extract fields from `appendResponse` tx receipt | +| `readFeedback` | Read a single feedback entry | +| `readAllFeedback` | Read all feedback (filtered by reviewers and tags, optional `batchSize`) | +| `getSummary` | Aggregated reputation summary | +| `getClients` | All addresses that have given feedback | +| `getLastIndex` | Latest feedback index for an agent-client pair | +| `getResponseCount` | Count responses to a feedback entry | +| `getIdentityRegistry` | Get linked Identity Registry address | +| `getVersion` | Read contract version string | + +## Chains + +| Chain | ID | +|---|---| +| Ethereum | 1 | +| Base | 8453 | +| Polygon | 137 | +| Arbitrum | 42161 | +| Optimism | 10 | +| Avalanche | 43114 | +| BSC | 56 | +| Scroll | 534352 | +| Linea | 59144 | +| Mantle | 5000 | +| Gnosis | 100 | +| Celo | 42220 | +| Base Sepolia | 84532 | +| Ethereum Sepolia | 11155111 | + +Registry addresses auto-resolve from `client.chain`. Pass `registryAddress` to override. + +## Exports + +| Path | Contents | +|---|---| +| `@x402r/erc8004` | Everything | +| `@x402r/erc8004/identity` | Identity registry functions and types | +| `@x402r/erc8004/reputation` | Reputation registry functions and types | +| `@x402r/erc8004/abis` | Raw contract ABIs | + +## License + +[Apache-2.0](./LICENSE) diff --git a/package.json b/package.json index c122e59..e204a9a 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,20 @@ { "name": "@x402r/erc8004", - "version": "0.0.0", + "version": "0.1.0-alpha.0", "description": "Lightweight TypeScript SDK for ERC-8004 (Trustless Agents) — Identity and Reputation registries", "type": "module", "license": "Apache-2.0", "sideEffects": false, + "repository": { + "type": "git", + "url": "git+https://github.com/BackTrackCo/erc8004.git" + }, + "homepage": "https://github.com/BackTrackCo/erc8004#readme", + "bugs": { + "url": "https://github.com/BackTrackCo/erc8004/issues" + }, "engines": { - "node": ">=22" + "node": ">=18" }, "packageManager": "pnpm@10.23.0", "files": [ diff --git a/src/addresses.ts b/src/addresses.ts index 8d9905f..f6991b7 100644 --- a/src/addresses.ts +++ b/src/addresses.ts @@ -26,6 +26,13 @@ const ERC8004_REGISTRIES: Record = { 137: MAINNET_ADDRESSES, // Polygon (Polygonscan verified) 42161: MAINNET_ADDRESSES, // Arbitrum (RPC verified, 8004scan indexes) 10: MAINNET_ADDRESSES, // Optimism (RPC verified, erc-8004 repo) + 43114: MAINNET_ADDRESSES, // Avalanche (CREATE2, erc-8004-contracts repo) + 56: MAINNET_ADDRESSES, // BSC (CREATE2, erc-8004-contracts repo) + 534352: MAINNET_ADDRESSES, // Scroll (CREATE2, erc-8004-contracts repo) + 59144: MAINNET_ADDRESSES, // Linea (CREATE2, erc-8004-contracts repo) + 5000: MAINNET_ADDRESSES, // Mantle (CREATE2, erc-8004-contracts repo) + 100: MAINNET_ADDRESSES, // Gnosis (CREATE2, erc-8004-contracts repo) + 42220: MAINNET_ADDRESSES, // Celo (CREATE2, erc-8004-contracts repo) // Testnets — verified via agent0-sdk + RPC 84532: TESTNET_ADDRESSES, // Base Sepolia 11155111: TESTNET_ADDRESSES, // Ethereum Sepolia diff --git a/src/identity/getVersion.ts b/src/identity/getVersion.ts new file mode 100644 index 0000000..fa1ac5e --- /dev/null +++ b/src/identity/getVersion.ts @@ -0,0 +1,21 @@ +import type { PublicClient } from 'viem' +import { identityRegistryAbi } from '../abis/index.js' +import { resolveIdentityRegistry } from '../internal/resolveRegistryAddress.js' +import type { GetVersionParameters } from './types.js' + +/** Read the contract version string from the Identity Registry. */ +export async function getVersion( + publicClient: PublicClient, + parameters: GetVersionParameters = {}, +): Promise { + const registry = resolveIdentityRegistry( + publicClient, + parameters.registryAddress, + ) + + return publicClient.readContract({ + address: registry, + abi: identityRegistryAbi, + functionName: 'getVersion', + }) +} diff --git a/src/identity/index.ts b/src/identity/index.ts index eacf299..d2d13e1 100644 --- a/src/identity/index.ts +++ b/src/identity/index.ts @@ -1,19 +1,33 @@ export { getAgentWallet } from './getAgentWallet.js' export { getMetadata } from './getMetadata.js' +export { getVersion } from './getVersion.js' export { isRegistered } from './isRegistered.js' +export type { MetadataSetResult } from './parseMetadataSetReceipt.js' +export { parseMetadataSetReceipt } from './parseMetadataSetReceipt.js' +export type { RegisterResult } from './parseRegisterReceipt.js' +export { parseRegisterReceipt } from './parseRegisterReceipt.js' +export type { URIUpdatedResult } from './parseURIUpdatedReceipt.js' +export { parseURIUpdatedReceipt } from './parseURIUpdatedReceipt.js' export { registerAgent } from './register.js' export { resolveAgent } from './resolveAgent.js' export { setAgentURI } from './setAgentURI.js' +export { setAgentWallet } from './setAgentWallet.js' export { setMetadata } from './setMetadata.js' +export { signAgentWalletConsent } from './signAgentWalletConsent.js' export type { GetAgentWalletParameters, GetMetadataParameters, + GetVersionParameters, IsRegisteredParameters, RegisterAgentParameters, ResolveAgentParameters, ResolvedAgent, SetAgentURIParameters, + SetAgentWalletParameters, SetMetadataParameters, + SignAgentWalletConsentParameters, + UnsetAgentWalletParameters, VerifyAgentIdParameters, } from './types.js' +export { unsetAgentWallet } from './unsetAgentWallet.js' export { verifyAgentId } from './verifyAgentId.js' diff --git a/src/identity/parseMetadataSetReceipt.ts b/src/identity/parseMetadataSetReceipt.ts new file mode 100644 index 0000000..b9e9870 --- /dev/null +++ b/src/identity/parseMetadataSetReceipt.ts @@ -0,0 +1,24 @@ +import { type Hex, parseEventLogs, type TransactionReceipt } from 'viem' +import { identityRegistryAbi } from '../abis/index.js' + +export interface MetadataSetResult { + agentId: bigint + metadataKey: string + metadataValue: Hex +} + +/** Extract agentId, metadataKey, and metadataValue from a `setMetadata` transaction receipt. */ +export function parseMetadataSetReceipt( + receipt: TransactionReceipt, +): MetadataSetResult { + const logs = parseEventLogs({ + abi: identityRegistryAbi, + logs: receipt.logs, + eventName: 'MetadataSet', + }) + if (logs.length === 0) { + throw new Error('No MetadataSet event found in receipt') + } + const { agentId, metadataKey, metadataValue } = logs[0].args + return { agentId, metadataKey, metadataValue } +} diff --git a/src/identity/parseRegisterReceipt.ts b/src/identity/parseRegisterReceipt.ts new file mode 100644 index 0000000..b28cb59 --- /dev/null +++ b/src/identity/parseRegisterReceipt.ts @@ -0,0 +1,27 @@ +import { type Address, parseEventLogs, type TransactionReceipt } from 'viem' +import { identityRegistryAbi } from '../abis/index.js' + +export interface RegisterResult { + agentId: bigint + owner: Address + agentURI: string +} + +/** + * Extract the agentId, owner, and agentURI from a `registerAgent` transaction receipt. + * Parses the `Registered` event emitted by the Identity Registry. + */ +export function parseRegisterReceipt( + receipt: TransactionReceipt, +): RegisterResult { + const logs = parseEventLogs({ + abi: identityRegistryAbi, + logs: receipt.logs, + eventName: 'Registered', + }) + if (logs.length === 0) { + throw new Error('No Registered event found in receipt') + } + const { agentId, owner, agentURI } = logs[0].args + return { agentId, owner, agentURI } +} diff --git a/src/identity/parseURIUpdatedReceipt.ts b/src/identity/parseURIUpdatedReceipt.ts new file mode 100644 index 0000000..e8ccc6f --- /dev/null +++ b/src/identity/parseURIUpdatedReceipt.ts @@ -0,0 +1,24 @@ +import { type Address, parseEventLogs, type TransactionReceipt } from 'viem' +import { identityRegistryAbi } from '../abis/index.js' + +export interface URIUpdatedResult { + agentId: bigint + newURI: string + updatedBy: Address +} + +/** Extract agentId, newURI, and updatedBy from a `setAgentURI` transaction receipt. */ +export function parseURIUpdatedReceipt( + receipt: TransactionReceipt, +): URIUpdatedResult { + const logs = parseEventLogs({ + abi: identityRegistryAbi, + logs: receipt.logs, + eventName: 'URIUpdated', + }) + if (logs.length === 0) { + throw new Error('No URIUpdated event found in receipt') + } + const { agentId, newURI, updatedBy } = logs[0].args + return { agentId, newURI, updatedBy } +} diff --git a/src/identity/register.ts b/src/identity/register.ts index 6b67a81..27f2de5 100644 --- a/src/identity/register.ts +++ b/src/identity/register.ts @@ -20,7 +20,13 @@ export async function registerAgent( parameters.registryAddress, ) - if (metadata && metadata.length > 0) { + if (agentURI === undefined && metadata && metadata.length > 0) { + throw new Error( + 'metadata requires agentURI: the contract has no register(metadata) overload without a URI', + ) + } + + if (agentURI !== undefined && metadata && metadata.length > 0) { return walletClient.writeContract({ address: registry, abi: identityRegistryAbi, @@ -37,11 +43,22 @@ export async function registerAgent( }) } + if (agentURI !== undefined) { + return walletClient.writeContract({ + address: registry, + abi: identityRegistryAbi, + functionName: 'register', + args: [agentURI], + chain: walletClient.chain, + account, + }) + } + return walletClient.writeContract({ address: registry, abi: identityRegistryAbi, functionName: 'register', - args: [agentURI], + args: [], chain: walletClient.chain, account, }) diff --git a/src/identity/resolveAgent.ts b/src/identity/resolveAgent.ts index 45bbebf..9324524 100644 --- a/src/identity/resolveAgent.ts +++ b/src/identity/resolveAgent.ts @@ -9,9 +9,8 @@ import type { ResolveAgentParameters, ResolvedAgent } from './types.js' * Throws if the agentId does not exist (ERC-721 reverts on non-existent tokens). * Use `verifyAgentId` first if you need a safe boolean check. * - * If `owner !== agentWallet`, the agent NFT was transferred but the wallet - * wasn't updated. `ownerMismatch` is set to true as a warning — callers - * should decide which address to trust based on their use case. + * `agentWallet` is `address(0)` after NFT transfer or explicit `unsetAgentWallet()`. + * When `ownerMismatch` is true, use `owner` for the canonical address. */ export async function resolveAgent( publicClient: PublicClient, diff --git a/src/identity/setAgentWallet.ts b/src/identity/setAgentWallet.ts new file mode 100644 index 0000000..102d758 --- /dev/null +++ b/src/identity/setAgentWallet.ts @@ -0,0 +1,35 @@ +import type { Hash, WalletClient } from 'viem' +import { identityRegistryAbi } from '../abis/index.js' +import { requireAccount } from '../internal/requireAccount.js' +import { resolveIdentityRegistry } from '../internal/resolveRegistryAddress.js' +import type { SetAgentWalletParameters } from './types.js' + +/** + * Set the verified payment wallet for an agent. + * Requires an EIP-712 signature from `newWallet` proving consent. + * The wallet clears on NFT transfer — call this again after any transfer. + */ +export async function setAgentWallet( + walletClient: WalletClient, + parameters: SetAgentWalletParameters, +): Promise { + const account = requireAccount(walletClient) + const registry = resolveIdentityRegistry( + walletClient, + parameters.registryAddress, + ) + + return walletClient.writeContract({ + address: registry, + abi: identityRegistryAbi, + functionName: 'setAgentWallet', + args: [ + parameters.agentId, + parameters.newWallet, + parameters.deadline, + parameters.signature, + ], + chain: walletClient.chain, + account, + }) +} diff --git a/src/identity/signAgentWalletConsent.ts b/src/identity/signAgentWalletConsent.ts new file mode 100644 index 0000000..42b5480 --- /dev/null +++ b/src/identity/signAgentWalletConsent.ts @@ -0,0 +1,69 @@ +import type { Address, Hex, WalletClient } from 'viem' +import { identityRegistryAbi } from '../abis/index.js' +import { requireAccount } from '../internal/requireAccount.js' +import { resolveIdentityRegistry } from '../internal/resolveRegistryAddress.js' +import type { SignAgentWalletConsentParameters } from './types.js' + +const AGENT_WALLET_SET_TYPES = { + AgentWalletSet: [ + { name: 'agentId', type: 'uint256' }, + { name: 'newWallet', type: 'address' }, + { name: 'owner', type: 'address' }, + { name: 'deadline', type: 'uint256' }, + ], +} as const + +/** + * Sign EIP-712 typed data proving consent from `newWallet` for `setAgentWallet`. + * + * The walletClient must be the `newWallet` signer. Pass a `publicClient` + * in parameters to read the current agent owner from the registry — the + * owner address is part of the EIP-712 struct and must match on-chain state. + * + * The returned signature is passed to `setAgentWallet` along with the + * same `agentId` and `deadline`. + * + * The contract enforces a max deadline of 5 minutes from `block.timestamp`. + */ +export async function signAgentWalletConsent( + walletClient: WalletClient, + parameters: SignAgentWalletConsentParameters, +): Promise { + const account = requireAccount(walletClient) + const registry = resolveIdentityRegistry( + walletClient, + parameters.registryAddress, + ) + + const { publicClient } = parameters + + const owner = await publicClient.readContract({ + address: registry, + abi: identityRegistryAbi, + functionName: 'ownerOf', + args: [parameters.agentId], + }) + + const chainId = walletClient.chain?.id + if (!chainId) { + throw new Error('walletClient chain not configured') + } + + return walletClient.signTypedData({ + account, + domain: { + name: 'ERC8004IdentityRegistry', + version: '1', + chainId: BigInt(chainId), + verifyingContract: registry, + }, + types: AGENT_WALLET_SET_TYPES, + primaryType: 'AgentWalletSet', + message: { + agentId: parameters.agentId, + newWallet: parameters.newWallet, + owner: owner as Address, + deadline: parameters.deadline, + }, + }) +} diff --git a/src/identity/types.ts b/src/identity/types.ts index 1d16b7b..c1d5364 100644 --- a/src/identity/types.ts +++ b/src/identity/types.ts @@ -1,8 +1,8 @@ -import type { Address, Hex } from 'viem' +import type { Address, Hex, PublicClient } from 'viem' export interface RegisterAgentParameters { registryAddress?: Address - agentURI: string + agentURI?: string metadata?: Array<{ key: string; value: Hex }> } @@ -35,6 +35,10 @@ export interface GetAgentWalletParameters { agentId: bigint } +export interface GetVersionParameters { + registryAddress?: Address +} + export interface GetMetadataParameters { registryAddress?: Address agentId: bigint @@ -53,3 +57,25 @@ export interface SetAgentURIParameters { agentId: bigint newURI: string } + +export interface SetAgentWalletParameters { + registryAddress?: Address + agentId: bigint + newWallet: Address + deadline: bigint + signature: Hex +} + +export interface SignAgentWalletConsentParameters { + /** PublicClient for reading the current agent owner from the registry. */ + publicClient: PublicClient + registryAddress?: Address + agentId: bigint + newWallet: Address + deadline: bigint +} + +export interface UnsetAgentWalletParameters { + registryAddress?: Address + agentId: bigint +} diff --git a/src/identity/unsetAgentWallet.ts b/src/identity/unsetAgentWallet.ts new file mode 100644 index 0000000..9a44ec4 --- /dev/null +++ b/src/identity/unsetAgentWallet.ts @@ -0,0 +1,29 @@ +import type { Hash, WalletClient } from 'viem' +import { identityRegistryAbi } from '../abis/index.js' +import { requireAccount } from '../internal/requireAccount.js' +import { resolveIdentityRegistry } from '../internal/resolveRegistryAddress.js' +import type { UnsetAgentWalletParameters } from './types.js' + +/** + * Clear the agent wallet, reverting to the NFT owner as the default. + * Only the agent owner or an approved operator can call this. + */ +export async function unsetAgentWallet( + walletClient: WalletClient, + parameters: UnsetAgentWalletParameters, +): Promise { + const account = requireAccount(walletClient) + const registry = resolveIdentityRegistry( + walletClient, + parameters.registryAddress, + ) + + return walletClient.writeContract({ + address: registry, + abi: identityRegistryAbi, + functionName: 'unsetAgentWallet', + args: [parameters.agentId], + chain: walletClient.chain, + account, + }) +} diff --git a/src/index.ts b/src/index.ts index 9c1b468..a450ec2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,45 +8,68 @@ export { export type { GetAgentWalletParameters, GetMetadataParameters, + GetVersionParameters, IsRegisteredParameters, + MetadataSetResult, RegisterAgentParameters, + RegisterResult, ResolveAgentParameters, ResolvedAgent, SetAgentURIParameters, + SetAgentWalletParameters, SetMetadataParameters, + SignAgentWalletConsentParameters, + UnsetAgentWalletParameters, + URIUpdatedResult, VerifyAgentIdParameters, } from './identity/index.js' export { getAgentWallet, getMetadata, + getVersion as getIdentityVersion, isRegistered, + parseMetadataSetReceipt, + parseRegisterReceipt, + parseURIUpdatedReceipt, registerAgent, resolveAgent, setAgentURI, + setAgentWallet, setMetadata, + signAgentWalletConsent, + unsetAgentWallet, verifyAgentId, } from './identity/index.js' export type { AppendResponseParameters, Feedback, FeedbackEntry, + FeedbackRevokedResult, GetClientsParameters, + GetIdentityRegistryParameters, GetLastIndexParameters, GetResponseCountParameters, GetSummaryParameters, GiveFeedbackParameters, + GiveFeedbackResult, ReadAllFeedbackParameters, ReadFeedbackParameters, ReputationSummary, + ResponseAppendedResult, RevokeFeedbackParameters, } from './reputation/index.js' export { appendResponse, getClients, + getIdentityRegistry, getLastIndex, getResponseCount, getSummary, + getVersion as getReputationVersion, giveFeedback, + parseFeedbackRevokedReceipt, + parseGiveFeedbackReceipt, + parseResponseAppendedReceipt, readAllFeedback, readFeedback, revokeFeedback, diff --git a/src/reputation/appendResponse.ts b/src/reputation/appendResponse.ts index 7526397..069bcbc 100644 --- a/src/reputation/appendResponse.ts +++ b/src/reputation/appendResponse.ts @@ -1,4 +1,4 @@ -import type { Hash, WalletClient } from 'viem' +import { type Hash, type WalletClient, zeroHash } from 'viem' import { reputationRegistryAbi } from '../abis/index.js' import { requireAccount } from '../internal/requireAccount.js' import { resolveReputationRegistry } from '../internal/resolveRegistryAddress.js' @@ -27,7 +27,7 @@ export async function appendResponse( parameters.clientAddress, parameters.feedbackIndex, parameters.responseURI, - parameters.responseHash, + parameters.responseHash ?? zeroHash, ], chain: walletClient.chain, account, diff --git a/src/reputation/getIdentityRegistry.ts b/src/reputation/getIdentityRegistry.ts new file mode 100644 index 0000000..3d704e3 --- /dev/null +++ b/src/reputation/getIdentityRegistry.ts @@ -0,0 +1,21 @@ +import type { Address, PublicClient } from 'viem' +import { reputationRegistryAbi } from '../abis/index.js' +import { resolveReputationRegistry } from '../internal/resolveRegistryAddress.js' +import type { GetIdentityRegistryParameters } from './types.js' + +/** Get the Identity Registry address linked to this Reputation Registry. */ +export async function getIdentityRegistry( + publicClient: PublicClient, + parameters: GetIdentityRegistryParameters = {}, +): Promise
{ + const registry = resolveReputationRegistry( + publicClient, + parameters.registryAddress, + ) + + return publicClient.readContract({ + address: registry, + abi: reputationRegistryAbi, + functionName: 'getIdentityRegistry', + }) +} diff --git a/src/reputation/getVersion.ts b/src/reputation/getVersion.ts new file mode 100644 index 0000000..b5b155a --- /dev/null +++ b/src/reputation/getVersion.ts @@ -0,0 +1,21 @@ +import type { PublicClient } from 'viem' +import { reputationRegistryAbi } from '../abis/index.js' +import { resolveReputationRegistry } from '../internal/resolveRegistryAddress.js' +import type { GetVersionParameters } from './types.js' + +/** Read the contract version string from the Reputation Registry. */ +export async function getVersion( + publicClient: PublicClient, + parameters: GetVersionParameters = {}, +): Promise { + const registry = resolveReputationRegistry( + publicClient, + parameters.registryAddress, + ) + + return publicClient.readContract({ + address: registry, + abi: reputationRegistryAbi, + functionName: 'getVersion', + }) +} diff --git a/src/reputation/giveFeedback.ts b/src/reputation/giveFeedback.ts index d8d26d4..7503381 100644 --- a/src/reputation/giveFeedback.ts +++ b/src/reputation/giveFeedback.ts @@ -6,8 +6,9 @@ import type { GiveFeedbackParameters } from './types.js' /** * Give feedback to an agent. The caller (msg.sender) is recorded as the - * reviewer (client). Feedback includes a numeric value, two category tags, - * and optional off-chain data (endpoint, URI, hash). + * reviewer (client). `value` is `int128` on-chain — negative values + * represent negative feedback. Two category tags and optional off-chain + * data (endpoint, URI, hash) can be attached. */ export async function giveFeedback( walletClient: WalletClient, diff --git a/src/reputation/index.ts b/src/reputation/index.ts index 33be68f..9f2d1a1 100644 --- a/src/reputation/index.ts +++ b/src/reputation/index.ts @@ -1,9 +1,17 @@ export { appendResponse } from './appendResponse.js' export { getClients } from './getClients.js' +export { getIdentityRegistry } from './getIdentityRegistry.js' export { getLastIndex } from './getLastIndex.js' export { getResponseCount } from './getResponseCount.js' export { getSummary } from './getSummary.js' +export { getVersion } from './getVersion.js' export { giveFeedback } from './giveFeedback.js' +export type { FeedbackRevokedResult } from './parseFeedbackRevokedReceipt.js' +export { parseFeedbackRevokedReceipt } from './parseFeedbackRevokedReceipt.js' +export type { GiveFeedbackResult } from './parseGiveFeedbackReceipt.js' +export { parseGiveFeedbackReceipt } from './parseGiveFeedbackReceipt.js' +export type { ResponseAppendedResult } from './parseResponseAppendedReceipt.js' +export { parseResponseAppendedReceipt } from './parseResponseAppendedReceipt.js' export { readAllFeedback } from './readAllFeedback.js' export { readFeedback } from './readFeedback.js' export { revokeFeedback } from './revokeFeedback.js' @@ -13,9 +21,11 @@ export type { Feedback, FeedbackEntry, GetClientsParameters, + GetIdentityRegistryParameters, GetLastIndexParameters, GetResponseCountParameters, GetSummaryParameters, + GetVersionParameters, GiveFeedbackParameters, ReadAllFeedbackParameters, ReadFeedbackParameters, diff --git a/src/reputation/parseFeedbackRevokedReceipt.ts b/src/reputation/parseFeedbackRevokedReceipt.ts new file mode 100644 index 0000000..7ebacf5 --- /dev/null +++ b/src/reputation/parseFeedbackRevokedReceipt.ts @@ -0,0 +1,24 @@ +import { type Address, parseEventLogs, type TransactionReceipt } from 'viem' +import { reputationRegistryAbi } from '../abis/index.js' + +export interface FeedbackRevokedResult { + agentId: bigint + clientAddress: Address + feedbackIndex: bigint +} + +/** Extract agentId, clientAddress, and feedbackIndex from a `revokeFeedback` transaction receipt. */ +export function parseFeedbackRevokedReceipt( + receipt: TransactionReceipt, +): FeedbackRevokedResult { + const logs = parseEventLogs({ + abi: reputationRegistryAbi, + logs: receipt.logs, + eventName: 'FeedbackRevoked', + }) + if (logs.length === 0) { + throw new Error('No FeedbackRevoked event found in receipt') + } + const { agentId, clientAddress, feedbackIndex } = logs[0].args + return { agentId, clientAddress, feedbackIndex } +} diff --git a/src/reputation/parseGiveFeedbackReceipt.ts b/src/reputation/parseGiveFeedbackReceipt.ts new file mode 100644 index 0000000..0c72532 --- /dev/null +++ b/src/reputation/parseGiveFeedbackReceipt.ts @@ -0,0 +1,50 @@ +import { + type Address, + type Hex, + parseEventLogs, + type TransactionReceipt, +} from 'viem' +import { reputationRegistryAbi } from '../abis/index.js' + +export interface GiveFeedbackResult { + agentId: bigint + clientAddress: Address + feedbackIndex: bigint + value: bigint + valueDecimals: number + tag1: string + tag2: string + endpoint: string + feedbackURI: string + feedbackHash: Hex +} + +/** + * Extract the agentId, clientAddress, and feedbackIndex from a `giveFeedback` transaction receipt. + * Parses the `NewFeedback` event emitted by the Reputation Registry. + */ +export function parseGiveFeedbackReceipt( + receipt: TransactionReceipt, +): GiveFeedbackResult { + const logs = parseEventLogs({ + abi: reputationRegistryAbi, + logs: receipt.logs, + eventName: 'NewFeedback', + }) + if (logs.length === 0) { + throw new Error('No NewFeedback event found in receipt') + } + const args = logs[0].args + return { + agentId: args.agentId, + clientAddress: args.clientAddress, + feedbackIndex: args.feedbackIndex, + value: args.value, + valueDecimals: args.valueDecimals, + tag1: args.tag1, + tag2: args.tag2, + endpoint: args.endpoint, + feedbackURI: args.feedbackURI, + feedbackHash: args.feedbackHash, + } +} diff --git a/src/reputation/parseResponseAppendedReceipt.ts b/src/reputation/parseResponseAppendedReceipt.ts new file mode 100644 index 0000000..343b2a0 --- /dev/null +++ b/src/reputation/parseResponseAppendedReceipt.ts @@ -0,0 +1,39 @@ +import { + type Address, + type Hex, + parseEventLogs, + type TransactionReceipt, +} from 'viem' +import { reputationRegistryAbi } from '../abis/index.js' + +export interface ResponseAppendedResult { + agentId: bigint + clientAddress: Address + feedbackIndex: bigint + responder: Address + responseURI: string + responseHash: Hex +} + +/** Extract fields from an `appendResponse` transaction receipt. */ +export function parseResponseAppendedReceipt( + receipt: TransactionReceipt, +): ResponseAppendedResult { + const logs = parseEventLogs({ + abi: reputationRegistryAbi, + logs: receipt.logs, + eventName: 'ResponseAppended', + }) + if (logs.length === 0) { + throw new Error('No ResponseAppended event found in receipt') + } + const args = logs[0].args + return { + agentId: args.agentId, + clientAddress: args.clientAddress, + feedbackIndex: args.feedbackIndex, + responder: args.responder, + responseURI: args.responseURI, + responseHash: args.responseHash, + } +} diff --git a/src/reputation/readAllFeedback.ts b/src/reputation/readAllFeedback.ts index 999153e..8ef70ff 100644 --- a/src/reputation/readAllFeedback.ts +++ b/src/reputation/readAllFeedback.ts @@ -9,10 +9,36 @@ import type { FeedbackEntry, ReadAllFeedbackParameters } from './types.js' * * The contract returns 7 parallel arrays — this function transforms them * into an array of structured objects for better DX. + * + * Pass `batchSize` to chunk `clientAddresses` into multiple RPC calls, + * avoiding response size limits for agents with many reviewers. + * Defaults to a single call when `batchSize` is omitted. */ export async function readAllFeedback( publicClient: PublicClient, parameters: ReadAllFeedbackParameters, +): Promise { + const { batchSize, ...rest } = parameters + + if (batchSize && rest.clientAddresses.length > batchSize) { + const results: FeedbackEntry[] = [] + for (let i = 0; i < rest.clientAddresses.length; i += batchSize) { + const chunk = rest.clientAddresses.slice(i, i + batchSize) + const batch = await readAllFeedbackSingle(publicClient, { + ...rest, + clientAddresses: chunk, + }) + results.push(...batch) + } + return results + } + + return readAllFeedbackSingle(publicClient, rest) +} + +async function readAllFeedbackSingle( + publicClient: PublicClient, + parameters: Omit, ): Promise { const registry = resolveReputationRegistry( publicClient, diff --git a/src/reputation/types.ts b/src/reputation/types.ts index 59fe679..963b8ad 100644 --- a/src/reputation/types.ts +++ b/src/reputation/types.ts @@ -1,5 +1,17 @@ import type { Address, Hex } from 'viem' +// --------------------------------------------------------------------------- +// Utility parameters +// --------------------------------------------------------------------------- + +export interface GetVersionParameters { + registryAddress?: Address +} + +export interface GetIdentityRegistryParameters { + registryAddress?: Address +} + // --------------------------------------------------------------------------- // Write parameters // --------------------------------------------------------------------------- @@ -7,6 +19,7 @@ import type { Address, Hex } from 'viem' export interface GiveFeedbackParameters { registryAddress?: Address agentId: bigint + /** int128 on-chain — negative values represent negative feedback. */ value: bigint valueDecimals: number tag1: string @@ -28,7 +41,7 @@ export interface AppendResponseParameters { clientAddress: Address feedbackIndex: bigint responseURI: string - responseHash: Hex + responseHash?: Hex } // --------------------------------------------------------------------------- @@ -61,6 +74,8 @@ export interface ReadAllFeedbackParameters { tag1: string tag2: string includeRevoked?: boolean + /** Chunk clientAddresses into batches of this size to avoid RPC limits. */ + batchSize?: number } export interface FeedbackEntry { diff --git a/tests/identity.test.ts b/tests/identity.test.ts index 007e03d..58c51a0 100644 --- a/tests/identity.test.ts +++ b/tests/identity.test.ts @@ -1,12 +1,24 @@ -import type { Address, PublicClient, WalletClient } from 'viem' +import type { + Address, + Log, + PublicClient, + TransactionReceipt, + WalletClient, +} from 'viem' +import { encodeAbiParameters, encodeEventTopics, getAddress } from 'viem' import { describe, expect, it, vi } from 'vitest' +import { identityRegistryAbi } from '../src/abis/index.js' import { getAgentWallet } from '../src/identity/getAgentWallet.js' import { getMetadata } from '../src/identity/getMetadata.js' import { isRegistered } from '../src/identity/isRegistered.js' +import { parseRegisterReceipt } from '../src/identity/parseRegisterReceipt.js' import { registerAgent } from '../src/identity/register.js' import { resolveAgent } from '../src/identity/resolveAgent.js' import { setAgentURI } from '../src/identity/setAgentURI.js' +import { setAgentWallet } from '../src/identity/setAgentWallet.js' import { setMetadata } from '../src/identity/setMetadata.js' +import { signAgentWalletConsent } from '../src/identity/signAgentWalletConsent.js' +import { unsetAgentWallet } from '../src/identity/unsetAgentWallet.js' import { verifyAgentId } from '../src/identity/verifyAgentId.js' import { ADDR_A, @@ -82,6 +94,44 @@ describe('registerAgent', () => { ) }) + it('uses zero-arg overload when agentURI is omitted', async () => { + const client = mockWallet() + await registerAgent(client, { + registryAddress: REGISTRY, + }) + + expect(client.writeContract).toHaveBeenCalledWith( + expect.objectContaining({ + functionName: 'register', + args: [], + }), + ) + }) + + it('treats empty string agentURI as register(""), not register()', async () => { + const client = mockWallet() + await registerAgent(client, { + registryAddress: REGISTRY, + agentURI: '', + }) + + expect(client.writeContract).toHaveBeenCalledWith( + expect.objectContaining({ + functionName: 'register', + args: [''], + }), + ) + }) + + it('throws when metadata provided without agentURI', async () => { + await expect( + registerAgent(mockWallet(), { + registryAddress: REGISTRY, + metadata: [{ key: 'k', value: '0xabcd' }], + }), + ).rejects.toThrow('metadata requires agentURI') + }) + it('propagates writeContract rejection', async () => { const client = { ...mockWallet(), @@ -312,6 +362,177 @@ describe('setAgentURI', () => { }) }) +// --- setAgentWallet --- + +describe('setAgentWallet', () => { + it('throws when walletClient has no account', async () => { + await expect( + setAgentWallet(mockWallet({ account: undefined }), { + registryAddress: REGISTRY, + agentId: 1n, + newWallet: ADDR_B, + deadline: 1000000n, + signature: '0xabcd', + }), + ).rejects.toThrow('walletClient must have an account') + }) + + it('passes args in correct order', async () => { + const client = mockWallet() + await setAgentWallet(client, { + registryAddress: REGISTRY, + agentId: 42n, + newWallet: ADDR_B, + deadline: 1000000n, + signature: '0xabcd', + }) + + expect(client.writeContract).toHaveBeenCalledWith( + expect.objectContaining({ + functionName: 'setAgentWallet', + args: [42n, ADDR_B, 1000000n, '0xabcd'], + }), + ) + }) +}) + +// --- unsetAgentWallet --- + +describe('unsetAgentWallet', () => { + it('throws when walletClient has no account', async () => { + await expect( + unsetAgentWallet(mockWallet({ account: undefined }), { + registryAddress: REGISTRY, + agentId: 1n, + }), + ).rejects.toThrow('walletClient must have an account') + }) + + it('passes agentId correctly', async () => { + const client = mockWallet() + await unsetAgentWallet(client, { + registryAddress: REGISTRY, + agentId: 42n, + }) + + expect(client.writeContract).toHaveBeenCalledWith( + expect.objectContaining({ + functionName: 'unsetAgentWallet', + args: [42n], + }), + ) + }) +}) + +// --- signAgentWalletConsent --- + +describe('signAgentWalletConsent', () => { + it('throws when walletClient has no account', async () => { + const publicClient = mockPublic({ ownerOf: ADDR_A }) + await expect( + signAgentWalletConsent(mockWallet({ account: undefined }), { + publicClient, + registryAddress: REGISTRY, + agentId: 1n, + newWallet: ADDR_B, + deadline: 1000000n, + }), + ).rejects.toThrow('walletClient must have an account') + }) + + it('reads ownerOf and calls signTypedData with correct EIP-712 structure', async () => { + const signTypedData = vi.fn().mockResolvedValue('0xsig') + const walletClient = { + account: { address: ADDR_B }, + chain: { id: 8453 }, + signTypedData, + } as unknown as WalletClient + + const publicClient = mockPublic({ ownerOf: ADDR_A }) + + await signAgentWalletConsent(walletClient, { + publicClient, + registryAddress: REGISTRY, + agentId: 42n, + newWallet: ADDR_B, + deadline: 1000000n, + }) + + expect(publicClient.readContract).toHaveBeenCalledWith( + expect.objectContaining({ + functionName: 'ownerOf', + args: [42n], + }), + ) + expect(signTypedData).toHaveBeenCalledWith( + expect.objectContaining({ + domain: { + name: 'ERC8004IdentityRegistry', + version: '1', + chainId: 8453n, + verifyingContract: REGISTRY, + }, + primaryType: 'AgentWalletSet', + message: { + agentId: 42n, + newWallet: ADDR_B, + owner: ADDR_A, + deadline: 1000000n, + }, + }), + ) + }) +}) + +// --- parseRegisterReceipt --- + +describe('parseRegisterReceipt', () => { + function makeRegisteredLog( + agentId: bigint, + owner: Address, + agentURI: string, + ): Log { + const topics = encodeEventTopics({ + abi: identityRegistryAbi, + eventName: 'Registered', + args: { agentId, owner: getAddress(owner) }, + }) + const data = encodeAbiParameters( + [{ type: 'string', name: 'agentURI' }], + [agentURI], + ) + return { + address: REGISTRY, + topics, + data, + blockHash: '0x0', + blockNumber: 1n, + logIndex: 0, + transactionHash: '0x0', + transactionIndex: 0, + removed: false, + } as unknown as Log + } + + it('extracts agentId, owner, and agentURI', () => { + const receipt = { + logs: [makeRegisteredLog(42n, ADDR_A, 'https://example.com/agent.json')], + } as unknown as TransactionReceipt + + const result = parseRegisterReceipt(receipt) + expect(result.agentId).toBe(42n) + expect(result.owner).toBe(getAddress(ADDR_A)) + expect(result.agentURI).toBe('https://example.com/agent.json') + }) + + it('throws when no Registered event in receipt', () => { + const receipt = { logs: [] } as unknown as TransactionReceipt + expect(() => parseRegisterReceipt(receipt)).toThrow( + 'No Registered event found in receipt', + ) + }) +}) + // --- registryAddress auto-resolve --- describe('registryAddress auto-resolve', () => { diff --git a/tests/reputation.test.ts b/tests/reputation.test.ts index 08b51d2..bea9c12 100644 --- a/tests/reputation.test.ts +++ b/tests/reputation.test.ts @@ -1,12 +1,19 @@ -import type { PublicClient } from 'viem' -import { zeroHash } from 'viem' +import type { Address, Log, PublicClient, TransactionReceipt } from 'viem' +import { + encodeAbiParameters, + encodeEventTopics, + getAddress, + zeroHash, +} from 'viem' import { describe, expect, it, vi } from 'vitest' +import { reputationRegistryAbi } from '../src/abis/index.js' import { appendResponse } from '../src/reputation/appendResponse.js' import { getClients } from '../src/reputation/getClients.js' import { getLastIndex } from '../src/reputation/getLastIndex.js' import { getResponseCount } from '../src/reputation/getResponseCount.js' import { getSummary } from '../src/reputation/getSummary.js' import { giveFeedback } from '../src/reputation/giveFeedback.js' +import { parseGiveFeedbackReceipt } from '../src/reputation/parseGiveFeedbackReceipt.js' import { readAllFeedback } from '../src/reputation/readAllFeedback.js' import { readFeedback } from '../src/reputation/readFeedback.js' import { revokeFeedback } from '../src/reputation/revokeFeedback.js' @@ -343,6 +350,144 @@ describe('getResponseCount', () => { }) }) +// --- readAllFeedback batchSize --- + +describe('readAllFeedback batchSize', () => { + it('makes a single call when within batch size', async () => { + const client = mockPublic({ + readAllFeedback: [ + [ADDR_A], + [1n], + [85n], + [0], + ['tag1'], + ['tag2'], + [false], + ], + }) + + const result = await readAllFeedback(client, { + registryAddress: REPUTATION_REGISTRY, + agentId: 42n, + clientAddresses: [ADDR_A], + tag1: 'tag1', + tag2: 'tag2', + batchSize: 50, + }) + + expect(result).toHaveLength(1) + expect(client.readContract).toHaveBeenCalledTimes(1) + }) + + it('chunks addresses into batches', async () => { + const addresses = Array.from( + { length: 5 }, + (_, i) => `0x${String(i + 1).padStart(40, '0')}` as Address, + ) + + // Each batch returns one entry per address + const client = { + chain: { id: 8453 }, + readContract: vi.fn(({ args }: { args: readonly unknown[] }) => { + const addrs = args[1] as Address[] + return Promise.resolve([ + addrs, + addrs.map(() => 1n), + addrs.map(() => 85n), + addrs.map(() => 0), + addrs.map(() => 'tag1'), + addrs.map(() => 'tag2'), + addrs.map(() => false), + ]) + }), + } as unknown as PublicClient + + const result = await readAllFeedback(client, { + registryAddress: REPUTATION_REGISTRY, + agentId: 42n, + clientAddresses: addresses, + tag1: 'tag1', + tag2: 'tag2', + batchSize: 2, + }) + + // 5 addresses / batch size 2 = 3 RPC calls + expect(client.readContract).toHaveBeenCalledTimes(3) + expect(result).toHaveLength(5) + }) +}) + +// --- parseGiveFeedbackReceipt --- + +describe('parseGiveFeedbackReceipt', () => { + function makeNewFeedbackLog( + agentId: bigint, + clientAddress: Address, + feedbackIndex: bigint, + ): Log { + const topics = encodeEventTopics({ + abi: reputationRegistryAbi, + eventName: 'NewFeedback', + args: { + agentId, + clientAddress: getAddress(clientAddress), + indexedTag1: 'x402r.resolution', + }, + }) + const data = encodeAbiParameters( + [ + { type: 'uint64', name: 'feedbackIndex' }, + { type: 'int128', name: 'value' }, + { type: 'uint8', name: 'valueDecimals' }, + { type: 'string', name: 'tag1' }, + { type: 'string', name: 'tag2' }, + { type: 'string', name: 'endpoint' }, + { type: 'string', name: 'feedbackURI' }, + { type: 'bytes32', name: 'feedbackHash' }, + ], + [feedbackIndex, 85n, 0, 'x402r.resolution', 'quality', '', '', zeroHash], + ) + return { + address: REPUTATION_REGISTRY, + topics, + data, + blockHash: '0x0', + blockNumber: 1n, + logIndex: 0, + transactionHash: '0x0', + transactionIndex: 0, + removed: false, + } as unknown as Log + } + + it('extracts all event fields', () => { + const receipt = { + logs: [makeNewFeedbackLog(42n, ADDR_A, 1n)], + } as unknown as TransactionReceipt + + const result = parseGiveFeedbackReceipt(receipt) + expect(result).toEqual({ + agentId: 42n, + clientAddress: getAddress(ADDR_A), + feedbackIndex: 1n, + value: 85n, + valueDecimals: 0, + tag1: 'x402r.resolution', + tag2: 'quality', + endpoint: '', + feedbackURI: '', + feedbackHash: zeroHash, + }) + }) + + it('throws when no NewFeedback event in receipt', () => { + const receipt = { logs: [] } as unknown as TransactionReceipt + expect(() => parseGiveFeedbackReceipt(receipt)).toThrow( + 'No NewFeedback event found in receipt', + ) + }) +}) + // --- registryAddress auto-resolve --- describe('registryAddress auto-resolve', () => { diff --git a/tests/setup/anvil.ts b/tests/setup/anvil.ts index ba1c371..ed03bbd 100644 --- a/tests/setup/anvil.ts +++ b/tests/setup/anvil.ts @@ -73,6 +73,7 @@ function defineAnvil(options: { chain, transport, cacheTime: 0, + pollingInterval: 100, }) }, @@ -98,8 +99,13 @@ function defineAnvil(options: { // Pre-configured instance for Base Sepolia fork // --------------------------------------------------------------------------- +const forkUrl = process.env.FORK_URL ?? 'https://sepolia.base.org' +if (!process.env.FORK_URL) { + console.warn('FORK_URL not set, using public RPC (rate-limited)') +} + export const anvilBaseSepolia = defineAnvil({ chain: baseSepolia, - forkUrl: process.env.FORK_URL ?? 'https://sepolia.base.org', + forkUrl, port: 8745, })