From a4e59a3a3f053bc1caff0c4df5370e57fb062e5f Mon Sep 17 00:00:00 2001
From: Cho Young-Hwi
Date: Fri, 24 Apr 2026 15:33:06 +0900
Subject: [PATCH 1/4] [#993] Redesign Link AI Writer: fetch tokenURI first,
pre-fill agent data
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add GET /api/user/lookup-agent endpoint: fetches agent via balanceOf +
tokenOfOwnerByIndex + tokenURI, with manual ID fallback
- Simplify POST /api/user/link-agent: accept pre-fetched agent metadata
(no RPC calls during linking step)
- Redesign LinkAIWriter component to multi-step flow: wallet input with
Lookup button → agent info card → binding signature → link
- Fix erc8004.ts: add tokenURI to ABI, prefer tokenURI over agentURI
(agentURI reverts on this contract)
- Bump version 1.0.2 → 1.1.0
Co-Authored-By: Claude Opus 4.6 (1M context)
---
lib/contracts/erc8004.ts | 53 ++++++---
package-lock.json | 4 +-
package.json | 2 +-
src/app/api/user/link-agent/route.ts | 128 +++------------------
src/app/api/user/lookup-agent/route.ts | 133 +++++++++++++++++++++
src/components/AgentRegister.tsx | 153 ++++++++++++++++++++-----
6 files changed, 315 insertions(+), 158 deletions(-)
create mode 100644 src/app/api/user/lookup-agent/route.ts
diff --git a/lib/contracts/erc8004.ts b/lib/contracts/erc8004.ts
index 3ad37faa..d78bc858 100644
--- a/lib/contracts/erc8004.ts
+++ b/lib/contracts/erc8004.ts
@@ -32,7 +32,7 @@ export const erc8004Abi = [
inputs: [{ name: "wallet", type: "address" }],
outputs: [{ name: "agentId", type: "uint256" }],
},
- // View — fetch metadata URI for an agent
+ // View — fetch metadata URI for an agent (ERC-8004 specific)
{
type: "function",
name: "agentURI",
@@ -40,6 +40,14 @@ export const erc8004Abi = [
inputs: [{ name: "agentId", type: "uint256" }],
outputs: [{ name: "", type: "string" }],
},
+ // View — ERC-721 standard token URI (preferred over agentURI)
+ {
+ type: "function",
+ name: "tokenURI",
+ stateMutability: "view",
+ inputs: [{ name: "tokenId", type: "uint256" }],
+ outputs: [{ name: "", type: "string" }],
+ },
// View — get the bound agent wallet for an agentId
{
type: "function",
@@ -303,12 +311,7 @@ export async function getAgentMetadata(
if (agentId <= BigInt(0)) return null;
const [uri, owner, agentWallet] = await Promise.all([
- publicClient.readContract({
- address: ERC8004_REGISTRY,
- abi: erc8004Abi,
- functionName: "agentURI",
- args: [agentId],
- }),
+ fetchTokenOrAgentURI(agentId),
publicClient.readContract({
address: ERC8004_REGISTRY,
abi: erc8004Abi,
@@ -350,12 +353,7 @@ export async function getAgentMetadataById(
): Promise {
try {
const [uri, owner, agentWallet] = await Promise.all([
- publicClient.readContract({
- address: ERC8004_REGISTRY,
- abi: erc8004Abi,
- functionName: "agentURI",
- args: [agentId],
- }),
+ fetchTokenOrAgentURI(agentId),
publicClient.readContract({
address: ERC8004_REGISTRY,
abi: erc8004Abi,
@@ -387,3 +385,32 @@ export async function getAgentMetadataById(
return null;
}
}
+
+/**
+ * Try tokenURI first (ERC-721 standard), fall back to agentURI.
+ * The ERC-8004 contract stores metadata via tokenURI; agentURI may revert.
+ */
+async function fetchTokenOrAgentURI(agentId: bigint): Promise {
+ try {
+ const uri = await publicClient.readContract({
+ address: ERC8004_REGISTRY,
+ abi: erc8004Abi,
+ functionName: "tokenURI",
+ args: [agentId],
+ });
+ if (uri) return uri as string;
+ } catch {
+ // tokenURI not available, try agentURI
+ }
+ try {
+ const uri = await publicClient.readContract({
+ address: ERC8004_REGISTRY,
+ abi: erc8004Abi,
+ functionName: "agentURI",
+ args: [agentId],
+ });
+ return (uri as string) || null;
+ } catch {
+ return null;
+ }
+}
diff --git a/package-lock.json b/package-lock.json
index 2b8aee5f..1d3c7abb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "plotlink",
- "version": "1.0.2",
+ "version": "1.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "plotlink",
- "version": "1.0.2",
+ "version": "1.1.0",
"workspaces": [
"packages/*"
],
diff --git a/package.json b/package.json
index 37bcfcce..6ea7ed66 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "plotlink",
- "version": "1.0.2",
+ "version": "1.1.0",
"private": true,
"workspaces": [
"packages/*"
diff --git a/src/app/api/user/link-agent/route.ts b/src/app/api/user/link-agent/route.ts
index 8b882b88..8a3d3683 100644
--- a/src/app/api/user/link-agent/route.ts
+++ b/src/app/api/user/link-agent/route.ts
@@ -1,24 +1,23 @@
import { NextRequest, NextResponse } from "next/server";
import { verifyMessage } from "viem";
import { createServiceRoleClient } from "../../../../../lib/supabase";
-import { getAgentMetadata } from "../../../../../lib/contracts/erc8004";
-import type { Address } from "viem";
/**
* POST /api/user/link-agent
* DB-only OWS agent linking: verifies the binding proof and sets
- * linked_agent_wallet on the human's user row. No ERC-8004 involvement
- * on the human side.
+ * linked_agent_wallet on the human's user row.
*
- * Body: { humanWallet, owsWallet, signature, humanSignature }
+ * Body: { humanWallet, owsWallet, signature, humanSignature, agentId?, agentName?, agentDescription?, agentGenre? }
* - signature: OWS wallet proves it authorized this human as owner
- * - humanSignature: Human wallet proves it owns the address (prevents
- * anyone with the OWS binding sig from linking to an arbitrary wallet)
+ * - humanSignature: Human wallet proves it owns the address
+ * - agentId/agentName/agentDescription/agentGenre: pre-fetched from lookup-agent endpoint
+ *
+ * No RPC calls — all agent data comes pre-fetched from the frontend.
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json();
- const { humanWallet, owsWallet, signature, humanSignature, agentId: providedAgentId, agentName, agentDescription, agentGenre } = body;
+ const { humanWallet, owsWallet, signature, humanSignature, agentId, agentName, agentDescription, agentGenre } = body;
if (!humanWallet || !owsWallet || !signature || !humanSignature) {
return NextResponse.json(
@@ -109,7 +108,6 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: updateError.message }, { status: 500 });
}
} else {
- // Create minimal user row with the link
const { error: insertError } = await supabase.from("users").insert({
primary_address: normalizedHuman,
linked_agent_wallet: normalizedOws,
@@ -120,8 +118,7 @@ export async function POST(request: NextRequest) {
}
}
- // Ensure the OWS wallet has a user row so its profile page works.
- // If it already registered via ERC-8004, this will find it; otherwise create a minimal row.
+ // Ensure the OWS wallet has a user row with agent metadata
const { data: owsUser } = await supabase
.from("users")
.select("id")
@@ -129,113 +126,22 @@ export async function POST(request: NextRequest) {
.limit(1)
.single();
- // Build agent fields — use provided agentId if available, try RPC as fallback
- let agentFields: Record = {};
- const agentId = providedAgentId ? Number(providedAgentId) : null;
-
- if (agentId) {
- // Agent ID provided by client — verify on-chain that the OWS wallet owns this NFT
- try {
- const { publicClient } = await import("../../../../../lib/rpc");
- const owner = await publicClient.readContract({
- address: "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432" as `0x${string}`,
- abi: [{ type: "function", name: "ownerOf", stateMutability: "view", inputs: [{ name: "tokenId", type: "uint256" }], outputs: [{ name: "", type: "address" }] }] as const,
- functionName: "ownerOf",
- args: [BigInt(agentId)],
- }) as string;
-
- if (owner.toLowerCase() === normalizedOws) {
- agentFields = { agent_id: agentId };
- // Fetch metadata (best effort)
- try {
- const { getAgentMetadataById } = await import("../../../../../lib/contracts/erc8004");
- const meta = await getAgentMetadataById(BigInt(agentId));
- if (meta) {
- agentFields.agent_name = meta.name || null;
- agentFields.agent_description = meta.description || null;
- agentFields.agent_genre = meta.genre || null;
- agentFields.agent_registered_at = meta.registeredAt || new Date().toISOString();
- }
- } catch { /* metadata fetch failed — agent_id is still set */ }
- }
- // If owner doesn't match, ignore the provided agentId (don't trust it)
- } catch { /* ownerOf RPC failed — ignore provided agentId */ }
- } else {
- // No agentId provided — try agentIdByWallet first, then balanceOf fallback
- try {
- const meta = await getAgentMetadata(normalizedOws as Address);
- if (meta?.agentId) {
- agentFields = {
- agent_id: Number(meta.agentId),
- agent_name: meta.name || null,
- agent_description: meta.description || null,
- agent_genre: meta.genre || null,
- agent_registered_at: meta.registeredAt || new Date().toISOString(),
- };
- }
- } catch { /* agentIdByWallet may revert for unbound wallets */ }
-
- // balanceOf fallback: wallet owns an NFT but isn't bound
- if (!agentFields.agent_id) {
- try {
- const { publicClient } = await import("../../../../../lib/rpc");
- const balance = await publicClient.readContract({
- address: "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432" as `0x${string}`,
- abi: [{ type: "function", name: "balanceOf", stateMutability: "view", inputs: [{ name: "owner", type: "address" }], outputs: [{ name: "", type: "uint256" }] }] as const,
- functionName: "balanceOf",
- args: [normalizedOws as `0x${string}`],
- }) as bigint;
-
- if (balance > BigInt(0)) {
- // Get the token ID via tokenOfOwnerByIndex (may not be supported)
- try {
- const tokenId = await publicClient.readContract({
- address: "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432" as `0x${string}`,
- abi: [{ type: "function", name: "tokenOfOwnerByIndex", stateMutability: "view", inputs: [{ name: "owner", type: "address" }, { name: "index", type: "uint256" }], outputs: [{ name: "", type: "uint256" }] }] as const,
- functionName: "tokenOfOwnerByIndex",
- args: [normalizedOws as `0x${string}`, BigInt(0)],
- }) as bigint;
- agentFields.agent_id = Number(tokenId);
-
- // Fetch metadata by ID
- try {
- const { getAgentMetadataById } = await import("../../../../../lib/contracts/erc8004");
- const meta = await getAgentMetadataById(tokenId);
- if (meta) {
- agentFields.agent_name = meta.name || null;
- agentFields.agent_description = meta.description || null;
- agentFields.agent_genre = meta.genre || null;
- }
- } catch { /* metadata lookup failed */ }
- } catch { /* tokenOfOwnerByIndex not supported — set flag without ID */ }
-
- // Even without token ID, mark as registered (has NFT)
- if (!agentFields.agent_id) {
- agentFields.agent_name = "AI Writer";
- }
- }
- } catch { /* balanceOf RPC failed */ }
- }
- }
-
- // Fill missing fields from client-provided values (from plotlink-ows config.json)
- if (!agentFields.agent_name && agentName) agentFields.agent_name = agentName;
- if (!agentFields.agent_description && agentDescription) agentFields.agent_description = agentDescription;
- if (!agentFields.agent_genre && agentGenre) agentFields.agent_genre = agentGenre;
+ const agentFields = {
+ agent_owner: normalizedHuman,
+ agent_type: "ows-writer" as const,
+ ...(agentId ? { agent_id: Number(agentId) } : {}),
+ ...(agentName ? { agent_name: agentName as string } : {}),
+ ...(agentDescription ? { agent_description: agentDescription as string } : {}),
+ ...(agentGenre ? { agent_genre: agentGenre as string } : {}),
+ };
try {
if (owsUser) {
- await supabase.from("users").update({
- agent_owner: normalizedHuman,
- agent_type: "ows-writer",
- ...agentFields,
- }).eq("id", owsUser.id);
+ await supabase.from("users").update(agentFields).eq("id", owsUser.id);
} else {
await supabase.from("users").insert({
primary_address: normalizedOws,
agent_wallet: normalizedOws,
- agent_owner: normalizedHuman,
- agent_type: "ows-writer",
...agentFields,
});
}
diff --git a/src/app/api/user/lookup-agent/route.ts b/src/app/api/user/lookup-agent/route.ts
new file mode 100644
index 00000000..ce6d7049
--- /dev/null
+++ b/src/app/api/user/lookup-agent/route.ts
@@ -0,0 +1,133 @@
+import { NextRequest, NextResponse } from "next/server";
+import { type Address } from "viem";
+import { publicClient } from "../../../../../lib/rpc";
+import { erc8004Abi, resolveAgentURI } from "../../../../../lib/contracts/erc8004";
+import { ERC8004_REGISTRY } from "../../../../../lib/contracts/constants";
+
+/**
+ * GET /api/user/lookup-agent?wallet=0x...
+ * Looks up an OWS wallet's agent data via RPC:
+ * 1. balanceOf(wallet) — confirm wallet owns an agent NFT
+ * 2. tokenOfOwnerByIndex(wallet, 0) — get agent ID
+ * 3. tokenURI(agentId) — get full metadata JSON
+ *
+ * If tokenOfOwnerByIndex is not available, returns found=false
+ * with a flag so the frontend can prompt for manual agent ID entry.
+ */
+export async function GET(request: NextRequest) {
+ const wallet = request.nextUrl.searchParams.get("wallet");
+ const manualAgentId = request.nextUrl.searchParams.get("agentId");
+
+ if (!wallet || !/^0x[a-fA-F0-9]{40}$/.test(wallet)) {
+ return NextResponse.json(
+ { error: "Valid wallet address is required" },
+ { status: 400 },
+ );
+ }
+
+ try {
+ // If manual agentId provided, verify ownership and fetch metadata
+ if (manualAgentId) {
+ const agentId = BigInt(manualAgentId);
+ const owner = await publicClient.readContract({
+ address: ERC8004_REGISTRY,
+ abi: erc8004Abi,
+ functionName: "ownerOf",
+ args: [agentId],
+ });
+
+ if ((owner as string).toLowerCase() !== wallet.toLowerCase()) {
+ return NextResponse.json({
+ found: false,
+ error: "This wallet does not own the specified agent",
+ });
+ }
+
+ return NextResponse.json(await buildAgentResponse(agentId));
+ }
+
+ // Check balance first
+ const balance = await publicClient.readContract({
+ address: ERC8004_REGISTRY,
+ abi: erc8004Abi,
+ functionName: "balanceOf",
+ args: [wallet as Address],
+ });
+
+ if ((balance as bigint) <= BigInt(0)) {
+ return NextResponse.json({
+ found: false,
+ error: "No agent NFT found for this wallet",
+ });
+ }
+
+ // Try enumerable lookup
+ let agentId: bigint;
+ try {
+ agentId = (await publicClient.readContract({
+ address: ERC8004_REGISTRY,
+ abi: erc8004Abi,
+ functionName: "tokenOfOwnerByIndex",
+ args: [wallet as Address, BigInt(0)],
+ })) as bigint;
+ } catch {
+ // tokenOfOwnerByIndex not supported — ask user for manual entry
+ return NextResponse.json({
+ found: false,
+ needsManualId: true,
+ error: "Could not auto-detect agent ID. Please enter it manually.",
+ });
+ }
+
+ return NextResponse.json(await buildAgentResponse(agentId));
+ } catch (err) {
+ return NextResponse.json(
+ { found: false, error: err instanceof Error ? err.message : "RPC lookup failed" },
+ { status: 502 },
+ );
+ }
+}
+
+async function buildAgentResponse(agentId: bigint) {
+ // Try tokenURI first, fall back to agentURI
+ let uri: string | null = null;
+ try {
+ uri = (await publicClient.readContract({
+ address: ERC8004_REGISTRY,
+ abi: erc8004Abi,
+ functionName: "tokenURI",
+ args: [agentId],
+ })) as string;
+ } catch {
+ try {
+ uri = (await publicClient.readContract({
+ address: ERC8004_REGISTRY,
+ abi: erc8004Abi,
+ functionName: "agentURI",
+ args: [agentId],
+ })) as string;
+ } catch {
+ // Neither URI method worked
+ }
+ }
+
+ if (!uri) {
+ return {
+ found: true,
+ agentId: agentId.toString(),
+ name: "Unknown Agent",
+ description: "",
+ };
+ }
+
+ const parsed = await resolveAgentURI(uri);
+ return {
+ found: true,
+ agentId: agentId.toString(),
+ 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,
+ registeredAt: (parsed.registeredAt as string) || undefined,
+ };
+}
diff --git a/src/components/AgentRegister.tsx b/src/components/AgentRegister.tsx
index 8c35ec44..ecbacc0f 100644
--- a/src/components/AgentRegister.tsx
+++ b/src/components/AgentRegister.tsx
@@ -43,30 +43,90 @@ const SET_WALLET_TYPES = {
* ERC-8004 registration happens on the OWS wallet via plotlink-ows.
* ───────────────────────────────────────────────────────────────────────────── */
+interface AgentInfo {
+ agentId: string;
+ name: string;
+ description: string;
+ genre?: string;
+ llmModel?: string;
+ registeredAt?: string;
+}
+
function LinkAIWriter() {
const { address } = useAccount();
const { signMessageAsync } = useSignMessage();
const [owsWallet, setOwsWallet] = useState("");
+ const [lookingUp, setLookingUp] = useState(false);
+ const [agentInfo, setAgentInfo] = useState(null);
+ const [needsManualId, setNeedsManualId] = useState(false);
+ const [manualAgentId, setManualAgentId] = useState("");
const [bindingSignature, setBindingSignature] = useState("");
- const [agentIdInput, setAgentIdInput] = useState("");
const [linking, setLinking] = useState(false);
const [done, setDone] = useState(false);
const [error, setError] = useState(null);
- const validInputs = /^0x[a-fA-F0-9]{40}$/.test(owsWallet) && bindingSignature.startsWith("0x") && bindingSignature.length > 10 && /^\d+$/.test(agentIdInput.trim());
+ const validWallet = /^0x[a-fA-F0-9]{40}$/.test(owsWallet);
+ const validSignature = bindingSignature.startsWith("0x") && bindingSignature.length > 10;
+
+ async function handleLookup() {
+ if (!validWallet) return;
+ try {
+ setError(null);
+ setLookingUp(true);
+ setAgentInfo(null);
+ setNeedsManualId(false);
+
+ const params = new URLSearchParams({ wallet: owsWallet });
+ const res = await fetch(`/api/user/lookup-agent?${params}`);
+ const data = await res.json();
+
+ if (data.found) {
+ setAgentInfo(data);
+ } else if (data.needsManualId) {
+ setNeedsManualId(true);
+ } else {
+ setError(data.error || "No agent found for this wallet");
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Lookup failed");
+ } finally {
+ setLookingUp(false);
+ }
+ }
+
+ async function handleManualLookup() {
+ if (!validWallet || !manualAgentId) return;
+ try {
+ setError(null);
+ setLookingUp(true);
+
+ const params = new URLSearchParams({ wallet: owsWallet, agentId: manualAgentId });
+ const res = await fetch(`/api/user/lookup-agent?${params}`);
+ const data = await res.json();
+
+ if (data.found) {
+ setAgentInfo(data);
+ setNeedsManualId(false);
+ } else {
+ setError(data.error || "Agent not found or not owned by this wallet");
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Lookup failed");
+ } finally {
+ setLookingUp(false);
+ }
+ }
async function handleLink() {
- if (!address) return;
+ if (!address || !agentInfo) return;
try {
setError(null);
setLinking(true);
- // Sign ownership proof with human wallet
const humanMessage = `I am linking OWS wallet ${owsWallet} to my PlotLink account. Wallet: ${address}`;
const humanSignature = await signMessageAsync({ message: humanMessage });
- // Verify both proofs and save DB link in one call
const res = await fetch("/api/user/link-agent", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -75,7 +135,10 @@ function LinkAIWriter() {
owsWallet,
signature: bindingSignature,
humanSignature,
- ...(agentIdInput.trim() && { agentId: Number(agentIdInput.trim()) }),
+ agentId: agentInfo.agentId,
+ agentName: agentInfo.name,
+ agentDescription: agentInfo.description,
+ agentGenre: agentInfo.genre,
}),
});
const data = await res.json();
@@ -97,11 +160,8 @@ function LinkAIWriter() {
Linked! Your AI writer is connected to your account.
OWS wallet: {truncateAddress(owsWallet)}
+ {agentInfo &&
Agent: {agentInfo.name} (ID: {agentInfo.agentId})
}
-
- Your OWS writer will register itself on-chain via ERC-8004 automatically.
- Your profile will show "Operates: AI Writer" once the agent is registered.
-
);
}
@@ -112,36 +172,67 @@ function LinkAIWriter() {
Connect your local PlotLink OWS Writer app to your PlotLink account.
Your AI writer will appear as "{address ? `${address.slice(0, 6)}...` : "your"}'s AI Writer" on PlotLink.
-
-
1. Open PlotLink OWS app → Settings → "Link to PlotLink"
-
2. Enter your PlotLink wallet address → app generates a binding code
-
3. Paste the OWS wallet address and binding code below