From 7c0af0d8091944c3608f220798cfc87d095690dd Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Fri, 27 Mar 2026 09:39:57 +0000 Subject: [PATCH 1/2] [#289] Overhaul ERC-8004 agent integration Fix critical bug: wallet binding deadline from 3600s to 300s (contract enforces max 5 minutes). Add missing ABI entries (getAgentWallet, unsetAgentWallet, setAgentURI, setMetadata, ownerOf, balanceOf, tokenOfOwnerByIndex). Detect existing agents on page load via agentIdByWallet + balanceOf, distinguish owner vs agent wallet roles. New AgentManage component for existing agents: view info, update URI, change/unset agent wallet. Dashboard now handles both owner and agent wallet connections. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/contracts/erc8004.ts | 101 +++++- src/app/agents/page.tsx | 66 +++- src/components/AgentDashboard.tsx | 61 +++- src/components/AgentManage.tsx | 505 ++++++++++++++++++++++++++++++ src/components/AgentRegister.tsx | 2 +- 5 files changed, 719 insertions(+), 16 deletions(-) create mode 100644 src/components/AgentManage.tsx diff --git a/lib/contracts/erc8004.ts b/lib/contracts/erc8004.ts index ef028e2c..bb3a9968 100644 --- a/lib/contracts/erc8004.ts +++ b/lib/contracts/erc8004.ts @@ -20,7 +20,7 @@ export interface AgentMetadata { } export const erc8004Abi = [ - // View + // View — reverse lookup wallet → agentId { type: "function", name: "agentIdByWallet", @@ -28,6 +28,7 @@ export const erc8004Abi = [ inputs: [{ name: "wallet", type: "address" }], outputs: [{ name: "agentId", type: "uint256" }], }, + // View — fetch metadata URI for an agent { type: "function", name: "agentURI", @@ -35,6 +36,52 @@ export const erc8004Abi = [ inputs: [{ name: "agentId", type: "uint256" }], outputs: [{ name: "", type: "string" }], }, + // View — get the bound agent wallet for an agentId + { + type: "function", + name: "getAgentWallet", + stateMutability: "view", + inputs: [{ name: "agentId", type: "uint256" }], + outputs: [{ name: "", type: "address" }], + }, + // View — get arbitrary metadata by key + { + type: "function", + name: "getMetadata", + stateMutability: "view", + inputs: [ + { name: "agentId", type: "uint256" }, + { name: "metadataKey", type: "string" }, + ], + outputs: [{ name: "", type: "bytes" }], + }, + // View — ERC-721: owner of a token (agentId) + { + type: "function", + name: "ownerOf", + stateMutability: "view", + inputs: [{ name: "tokenId", type: "uint256" }], + outputs: [{ name: "owner", type: "address" }], + }, + // View — ERC-721: number of tokens owned by an address + { + type: "function", + name: "balanceOf", + stateMutability: "view", + inputs: [{ name: "owner", type: "address" }], + outputs: [{ name: "", type: "uint256" }], + }, + // View — ERC-721 Enumerable: token at index for owner + { + type: "function", + name: "tokenOfOwnerByIndex", + stateMutability: "view", + inputs: [ + { name: "owner", type: "address" }, + { name: "index", type: "uint256" }, + ], + outputs: [{ name: "", type: "uint256" }], + }, // Write — register a new agent { type: "function", @@ -56,6 +103,37 @@ export const erc8004Abi = [ ], outputs: [], }, + // Write — remove agent wallet binding + { + type: "function", + name: "unsetAgentWallet", + stateMutability: "nonpayable", + inputs: [{ name: "agentId", type: "uint256" }], + outputs: [], + }, + // Write — update agent URI (owner/approved only) + { + type: "function", + name: "setAgentURI", + stateMutability: "nonpayable", + inputs: [ + { name: "agentId", type: "uint256" }, + { name: "newURI", type: "string" }, + ], + outputs: [], + }, + // Write — set arbitrary metadata key/value + { + type: "function", + name: "setMetadata", + stateMutability: "nonpayable", + inputs: [ + { name: "agentId", type: "uint256" }, + { name: "metadataKey", type: "string" }, + { name: "metadataValue", type: "bytes" }, + ], + outputs: [], + }, // Event — emitted on successful registration { type: "event", @@ -66,6 +144,27 @@ export const erc8004Abi = [ { name: "owner", type: "address", indexed: true }, ], }, + // Event — emitted when URI is updated + { + type: "event", + name: "URIUpdated", + inputs: [ + { name: "agentId", type: "uint256", indexed: true }, + { name: "newURI", type: "string", indexed: false }, + { name: "updatedBy", type: "address", indexed: true }, + ], + }, + // Event — emitted when metadata is set + { + type: "event", + name: "MetadataSet", + inputs: [ + { name: "agentId", type: "uint256", indexed: true }, + { name: "indexedMetadataKey", type: "string", indexed: true }, + { name: "metadataKey", type: "string", indexed: false }, + { name: "metadataValue", type: "bytes", indexed: false }, + ], + }, ] as const; /** diff --git a/src/app/agents/page.tsx b/src/app/agents/page.tsx index 14d9088e..2b6e3b6e 100644 --- a/src/app/agents/page.tsx +++ b/src/app/agents/page.tsx @@ -1,18 +1,70 @@ "use client"; import { useState } from "react"; -import { useAccount } from "wagmi"; +import { useAccount, useReadContract } from "wagmi"; +import { zeroAddress } from "viem"; import { ConnectWallet } from "../../components/ConnectWallet"; import { AgentRegister } from "../../components/AgentRegister"; +import { AgentManage } from "../../components/AgentManage"; import { AgentBuild } from "../../components/AgentBuild"; import { AgentDashboard } from "../../components/AgentDashboard"; +import { erc8004Abi } from "../../../lib/contracts/erc8004"; +import { ERC8004_REGISTRY } from "../../../lib/contracts/constants"; type Tab = "register" | "build" | "dashboard"; export default function AgentsPage() { - const { isConnected } = useAccount(); + const { isConnected, address } = useAccount(); const [tab, setTab] = useState("register"); + // Check if wallet is bound as an agent wallet + const { data: agentIdByWallet, isLoading: walletLoading } = useReadContract({ + address: ERC8004_REGISTRY, + abi: erc8004Abi, + functionName: "agentIdByWallet", + args: address ? [address] : undefined, + query: { enabled: !!address }, + }); + + // Check if wallet owns any agent NFTs (ERC-721 balanceOf) + const { data: nftBalance, isLoading: balanceLoading } = useReadContract({ + address: ERC8004_REGISTRY, + abi: erc8004Abi, + functionName: "balanceOf", + args: address ? [address] : undefined, + query: { enabled: !!address }, + }); + + // If owner, get the first owned token ID + const hasNft = nftBalance !== undefined && nftBalance > BigInt(0); + const { data: ownedTokenId, isLoading: tokenLoading } = useReadContract({ + address: ERC8004_REGISTRY, + abi: erc8004Abi, + functionName: "tokenOfOwnerByIndex", + args: address ? [address, BigInt(0)] : undefined, + query: { enabled: !!address && hasNft }, + }); + + const isAgentWallet = agentIdByWallet !== undefined && agentIdByWallet > BigInt(0); + const isOwner = hasNft && ownedTokenId !== undefined; + const detectLoading = walletLoading || balanceLoading || (hasNft && tokenLoading); + + // Determine which agentId and role to use + let detectedAgentId: bigint | undefined; + let detectedRole: "owner" | "agentWallet" | undefined; + if (isOwner) { + detectedAgentId = ownedTokenId; + detectedRole = "owner"; + } else if (isAgentWallet) { + detectedAgentId = agentIdByWallet; + detectedRole = "agentWallet"; + } + + const hasExistingAgent = detectedAgentId !== undefined && detectedRole !== undefined; + + // Determine the label for the first tab + const firstTabLabel = hasExistingAgent ? "Manage" : "Register"; + return (

@@ -34,7 +86,7 @@ export default function AgentsPage() { : "text-muted hover:text-foreground" }`} > - {t === "register" ? "Register" : t === "build" ? "Build" : "Dashboard"} + {t === "register" ? firstTabLabel : t === "build" ? "Build" : "Dashboard"} ))}

@@ -43,9 +95,15 @@ export default function AgentsPage() { {tab === "register" && ( !isConnected ? (
-

Connect your wallet to register an agent.

+

Connect your wallet to register or manage an agent.

+ ) : detectLoading ? ( +
+

Detecting agent status...

+
+ ) : hasExistingAgent ? ( + ) : ( ) diff --git a/src/components/AgentDashboard.tsx b/src/components/AgentDashboard.tsx index 0e9449ff..9205fc78 100644 --- a/src/components/AgentDashboard.tsx +++ b/src/components/AgentDashboard.tsx @@ -9,8 +9,8 @@ import { ERC8004_REGISTRY } from "../../lib/contracts/constants"; export function AgentDashboard() { const { address } = useAccount(); - // Check if wallet is registered as an agent - const { data: agentId, isLoading: agentIdLoading } = useReadContract({ + // Check if wallet is registered as an agent wallet + const { data: agentIdByWallet, isLoading: walletLoading } = useReadContract({ address: ERC8004_REGISTRY, abi: erc8004Abi, functionName: "agentIdByWallet", @@ -18,21 +18,55 @@ export function AgentDashboard() { query: { enabled: !!address }, }); - const isAgent = agentId !== undefined && agentId > BigInt(0); + // Check if wallet owns agent NFTs (owner role) + const { data: nftBalance, isLoading: balanceLoading } = useReadContract({ + address: ERC8004_REGISTRY, + abi: erc8004Abi, + functionName: "balanceOf", + args: address ? [address] : undefined, + query: { enabled: !!address }, + }); + + const hasNft = nftBalance !== undefined && nftBalance > BigInt(0); + const { data: ownedTokenId, isLoading: tokenLoading } = useReadContract({ + address: ERC8004_REGISTRY, + abi: erc8004Abi, + functionName: "tokenOfOwnerByIndex", + args: address ? [address, BigInt(0)] : undefined, + query: { enabled: !!address && hasNft }, + }); + + // Get the agent wallet for the owned token (to query storylines by that address) + const isOwner = hasNft && ownedTokenId !== undefined; + const { data: boundAgentWallet } = useReadContract({ + address: ERC8004_REGISTRY, + abi: erc8004Abi, + functionName: "getAgentWallet", + args: ownedTokenId !== undefined ? [ownedTokenId] : undefined, + query: { enabled: isOwner }, + }); + + const isAgentWallet = agentIdByWallet !== undefined && agentIdByWallet > BigInt(0); + const agentId = isOwner ? ownedTokenId : isAgentWallet ? agentIdByWallet : undefined; + const isAgent = agentId !== undefined; + + // Determine the writer address for storyline lookup + // If connected as owner, use the bound agent wallet; if connected as agent wallet, use current address + const writerAddress = isOwner && boundAgentWallet ? (boundAgentWallet as string) : address; // Fetch agent's storylines from Supabase const { data: storylines, isLoading: storylinesLoading } = useQuery({ - queryKey: ["agent-storylines", address], + queryKey: ["agent-storylines", writerAddress], queryFn: async () => { - if (!address) return []; - const res = await fetch(`/api/storyline/by-writer?writer=${address}&type=agent`); + if (!writerAddress) return []; + const res = await fetch(`/api/storyline/by-writer?writer=${writerAddress}&type=agent`); if (!res.ok) return []; return res.json() as Promise>; }, - enabled: !!address && isAgent, + enabled: !!writerAddress && isAgent, }); - if (agentIdLoading) { + if (walletLoading || balanceLoading || (hasNft && tokenLoading)) { return (

Loading agent status...

@@ -54,8 +88,15 @@ export function AgentDashboard() { return (
-

Agent #{agentId.toString()}

-

+

Agent #{agentId!.toString()}

+

+ {isOwner && isAgentWallet + ? "Connected as owner + agent wallet" + : isOwner + ? "Connected as owner" + : "Connected as agent wallet"} +

+

{address?.slice(0, 6)}...{address?.slice(-4)}

diff --git a/src/components/AgentManage.tsx b/src/components/AgentManage.tsx new file mode 100644 index 00000000..8627b026 --- /dev/null +++ b/src/components/AgentManage.tsx @@ -0,0 +1,505 @@ +"use client"; + +import { useState, useEffect, useMemo } from "react"; +import { useAccount, useWriteContract, useReadContract, useSignTypedData } from "wagmi"; +import { type Hex, type Address, zeroAddress } from "viem"; +import { browserClient as publicClient } from "../../lib/rpc"; +import { erc8004Abi, type AgentMetadata } from "../../lib/contracts/erc8004"; +import { ERC8004_REGISTRY, BASE_CHAIN_ID, EXPLORER_URL } from "../../lib/contracts/constants"; +import { Select } from "./Select"; + +const GENRES = [ + "Fantasy", "Sci-Fi", "Mystery", "Romance", "Horror", + "Thriller", "Literary Fiction", "Comedy", "Historical", "Adventure", +] as const; + +const LLM_MODELS = [ + "Claude Opus 4", "Claude Sonnet 4", "GPT-5", "GPT-4o", + "Gemini 2.5 Pro", "Llama 4 Maverick", "Custom / Other", +] as const; + +const EIP712_DOMAIN = { + name: "ERC8004IdentityRegistry", + version: "1", + chainId: BigInt(BASE_CHAIN_ID), + verifyingContract: ERC8004_REGISTRY, +} as const; + +const SET_WALLET_TYPES = { + AgentWalletSet: [ + { name: "agentId", type: "uint256" }, + { name: "newWallet", type: "address" }, + { name: "owner", type: "address" }, + { name: "deadline", type: "uint256" }, + ], +} as const; + +interface AgentManageProps { + agentId: bigint; + role: "owner" | "agentWallet"; +} + +export function AgentManage({ agentId, role }: AgentManageProps) { + const { address } = useAccount(); + const { writeContractAsync } = useWriteContract(); + const { signTypedDataAsync } = useSignTypedData(); + + const [metadata, setMetadata] = useState(null); + const [loadingMeta, setLoadingMeta] = useState(true); + const [error, setError] = useState(null); + const [txHash, setTxHash] = useState(); + + // Edit state for URI update + const [editing, setEditing] = useState(false); + const [editName, setEditName] = useState(""); + const [editDescription, setEditDescription] = useState(""); + const [editGenre, setEditGenre] = useState(""); + const [editLlmModel, setEditLlmModel] = useState(""); + const [savingUri, setSavingUri] = useState(false); + + // Wallet change state + const [changingWallet, setChangingWallet] = useState(false); + const [newWalletAddr, setNewWalletAddr] = useState(""); + const [walletSignature, setWalletSignature] = useState(); + const [walletDeadline, setWalletDeadline] = useState(); + const [walletStep, setWalletStep] = useState<"enter" | "sign" | "submit" | null>(null); + const [signingWallet, setSigningWallet] = useState(false); + const [submittingWallet, setSubmittingWallet] = useState(false); + + // Unset wallet state + const [unsettingWallet, setUnsettingWallet] = useState(false); + + // Fetch current agent wallet + const { data: currentAgentWallet } = useReadContract({ + address: ERC8004_REGISTRY, + abi: erc8004Abi, + functionName: "getAgentWallet", + args: [agentId], + }); + + // Fetch owner of the agent NFT + const { data: ownerAddr } = useReadContract({ + address: ERC8004_REGISTRY, + abi: erc8004Abi, + functionName: "ownerOf", + args: [agentId], + }); + + // Fetch metadata from URI + useEffect(() => { + let cancelled = false; + async function fetchMeta() { + try { + const uri = await publicClient.readContract({ + address: ERC8004_REGISTRY, + abi: erc8004Abi, + functionName: "agentURI", + args: [agentId], + }); + if (cancelled) return; + if (!uri) { setMetadata(null); return; } + const parsed = JSON.parse(uri as string) as Record; + setMetadata({ + name: (parsed.name as string) || "Unknown Agent", + description: (parsed.description as string) || "", + genre: (parsed.genre as string) || undefined, + llmModel: (parsed.llmModel as string) || (parsed.model as string) || undefined, + registeredBy: (parsed.registeredBy as string) || undefined, + registeredAt: (parsed.registeredAt as string) || undefined, + }); + } catch { + if (!cancelled) setMetadata(null); + } finally { + if (!cancelled) setLoadingMeta(false); + } + } + fetchMeta(); + return () => { cancelled = true; }; + }, [agentId]); + + // Populate edit fields when metadata loads + useEffect(() => { + if (metadata) { + setEditName(metadata.name); + setEditDescription(metadata.description); + setEditGenre(metadata.genre ?? ""); + setEditLlmModel(metadata.llmModel ?? ""); + } + }, [metadata]); + + const isOwner = role === "owner"; + const editUri = useMemo(() => { + if (!editName.trim()) return ""; + return JSON.stringify({ + name: editName.trim(), + description: editDescription.trim(), + genre: editGenre || undefined, + llmModel: editLlmModel || undefined, + registeredBy: metadata?.registeredBy ?? address, + registeredAt: metadata?.registeredAt ?? new Date().toISOString(), + }); + }, [editName, editDescription, editGenre, editLlmModel, metadata, address]); + + async function handleUpdateUri() { + if (!editUri) return; + try { + setError(null); + setSavingUri(true); + const hash = await writeContractAsync({ + address: ERC8004_REGISTRY, + abi: erc8004Abi, + functionName: "setAgentURI", + args: [agentId, editUri], + }); + setTxHash(hash); + await publicClient.waitForTransactionReceipt({ hash }); + const parsed = JSON.parse(editUri); + setMetadata({ ...metadata!, ...parsed }); + setEditing(false); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to update URI"); + } finally { + setSavingUri(false); + } + } + + async function handleUnsetWallet() { + try { + setError(null); + setUnsettingWallet(true); + const hash = await writeContractAsync({ + address: ERC8004_REGISTRY, + abi: erc8004Abi, + functionName: "unsetAgentWallet", + args: [agentId], + }); + setTxHash(hash); + await publicClient.waitForTransactionReceipt({ hash }); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to unset wallet"); + } finally { + setUnsettingWallet(false); + } + } + + async function handleSignNewWallet() { + if (!newWalletAddr || !address) return; + try { + setError(null); + setSigningWallet(true); + const deadline = BigInt(Math.floor(Date.now() / 1000) + 300); + const signature = await signTypedDataAsync({ + domain: EIP712_DOMAIN, + types: SET_WALLET_TYPES, + primaryType: "AgentWalletSet", + message: { + agentId, + newWallet: newWalletAddr as Address, + owner: (ownerAddr ?? address) as Address, + deadline, + }, + }); + setWalletSignature(signature); + setWalletDeadline(deadline); + setWalletStep("submit"); + } catch (err) { + setError(err instanceof Error ? err.message : "Signing failed"); + } finally { + setSigningWallet(false); + } + } + + async function handleSubmitNewWallet() { + if (!walletSignature || !walletDeadline || !newWalletAddr) return; + try { + setError(null); + setSubmittingWallet(true); + const hash = await writeContractAsync({ + address: ERC8004_REGISTRY, + abi: erc8004Abi, + functionName: "setAgentWallet", + args: [agentId, newWalletAddr as Address, walletDeadline, walletSignature], + }); + setTxHash(hash); + await publicClient.waitForTransactionReceipt({ hash }); + setWalletStep(null); + setChangingWallet(false); + setNewWalletAddr(""); + } catch (err) { + setError(err instanceof Error ? err.message : "Wallet binding failed"); + } finally { + setSubmittingWallet(false); + } + } + + const isNewWalletConnected = + address?.toLowerCase() === newWalletAddr.toLowerCase() && newWalletAddr.match(/^0x[a-fA-F0-9]{40}$/); + const isOwnerConnected = + ownerAddr && address?.toLowerCase() === (ownerAddr as string).toLowerCase(); + + if (loadingMeta) { + return ( +
+

Loading agent info...

+
+ ); + } + + return ( +
+ {/* Agent Info Header */} +
+
+
+

+ {metadata?.name ?? "Agent"} #{agentId.toString()} +

+

+ {role === "owner" ? "You own this agent" : "Your wallet is bound to this agent"} +

+
+ {isOwner && !editing && ( + + )} +
+
+ + {error && ( +
{error}
+ )} + + {txHash && ( + + )} + + {/* View / Edit Metadata */} + {editing ? ( +
+
+ + setEditName(e.target.value)} + className="border-border bg-surface text-foreground w-full rounded border px-3 py-2 text-sm focus:border-accent focus:outline-none" + /> +
+
+ +