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

-
+ {/* Step 1: OWS Wallet Address + Lookup */}
- setOwsWallet(e.target.value)} placeholder="0x..." - className="border-border bg-surface text-foreground placeholder:text-muted w-full rounded border px-3 py-2 text-sm font-mono focus:border-accent focus:outline-none" /> -
-
- - setBindingSignature(e.target.value)} placeholder="0x..." - className="border-border bg-surface text-foreground placeholder:text-muted w-full rounded border px-3 py-2 text-sm font-mono focus:border-accent focus:outline-none" /> -
-
- - setAgentIdInput(e.target.value)} placeholder="e.g. 17777" - className="border-border bg-surface text-foreground placeholder:text-muted w-full rounded border px-3 py-2 text-sm font-mono focus:border-accent focus:outline-none" /> +
+ { setOwsWallet(e.target.value); setAgentInfo(null); setNeedsManualId(false); setError(null); }} placeholder="0x..." + className="border-border bg-surface text-foreground placeholder:text-muted min-w-0 flex-1 rounded border px-3 py-2 text-sm font-mono focus:border-accent focus:outline-none" /> + +
+ {/* Manual Agent ID fallback */} + {needsManualId && ( +
+ +

Could not auto-detect agent ID. Enter it manually.

+
+ setManualAgentId(e.target.value.replace(/\D/g, ""))} placeholder="e.g. 45557" + className="border-border bg-surface text-foreground placeholder:text-muted min-w-0 flex-1 rounded border px-3 py-2 text-sm font-mono focus:border-accent focus:outline-none" /> + +
+
+ )} + + {/* Step 2: Agent Info Card */} + {agentInfo && ( +
+

Found: {agentInfo.name}

+

Agent ID: {agentInfo.agentId}

+ {agentInfo.description &&

{agentInfo.description}

} + {agentInfo.genre &&

Genre: {agentInfo.genre}

} +
+ )} + + {/* Step 3: Binding Signature (only shown after agent found) */} + {agentInfo && ( +
+ +

Paste the binding code from your OWS app (Settings → "Link to PlotLink")

+ setBindingSignature(e.target.value)} placeholder="0x..." + className="border-border bg-surface text-foreground placeholder:text-muted w-full rounded border px-3 py-2 text-sm font-mono focus:border-accent focus:outline-none" /> +
+ )} + {error && (
{error}
)} - + {/* Step 4: Link button */} + {agentInfo && ( + + )} ); } From f3ab9442260ce441bb2f0e7744606e73aab698e9 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Fri, 24 Apr 2026 15:36:03 +0900 Subject: [PATCH 2/4] [#993] Deduplicate URI fallback: export fetchTokenOrAgentURI, reuse in lookup-agent Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/contracts/erc8004.ts | 2 +- src/app/api/user/lookup-agent/route.ts | 24 ++---------------------- 2 files changed, 3 insertions(+), 23 deletions(-) diff --git a/lib/contracts/erc8004.ts b/lib/contracts/erc8004.ts index d78bc858..368aa322 100644 --- a/lib/contracts/erc8004.ts +++ b/lib/contracts/erc8004.ts @@ -390,7 +390,7 @@ export async function getAgentMetadataById( * 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 { +export async function fetchTokenOrAgentURI(agentId: bigint): Promise { try { const uri = await publicClient.readContract({ address: ERC8004_REGISTRY, diff --git a/src/app/api/user/lookup-agent/route.ts b/src/app/api/user/lookup-agent/route.ts index ce6d7049..afe3e412 100644 --- a/src/app/api/user/lookup-agent/route.ts +++ b/src/app/api/user/lookup-agent/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { type Address } from "viem"; import { publicClient } from "../../../../../lib/rpc"; -import { erc8004Abi, resolveAgentURI } from "../../../../../lib/contracts/erc8004"; +import { erc8004Abi, resolveAgentURI, fetchTokenOrAgentURI } from "../../../../../lib/contracts/erc8004"; import { ERC8004_REGISTRY } from "../../../../../lib/contracts/constants"; /** @@ -89,27 +89,7 @@ export async function GET(request: NextRequest) { } 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 - } - } + const uri = await fetchTokenOrAgentURI(agentId); if (!uri) { return { From d96885e30ea4ccde59b06171734dd169374dcc50 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Fri, 24 Apr 2026 15:37:27 +0900 Subject: [PATCH 3/4] [#993] Server-verify agent metadata: fetch canonical data from tokenURI on-chain - link-agent now verifies agentId ownership via ownerOf() and fetches canonical metadata from tokenURI server-side (prevents client poisoning) - All fields stored: agent_name, agent_description, agent_genre, agent_llm_model, agent_registered_at - Frontend simplified to only pass agentId (metadata comes from chain) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/user/link-agent/route.ts | 69 ++++++++++++++++++++++------ src/components/AgentRegister.tsx | 3 -- 2 files changed, 55 insertions(+), 17 deletions(-) diff --git a/src/app/api/user/link-agent/route.ts b/src/app/api/user/link-agent/route.ts index 8a3d3683..4673faa5 100644 --- a/src/app/api/user/link-agent/route.ts +++ b/src/app/api/user/link-agent/route.ts @@ -1,23 +1,26 @@ import { NextRequest, NextResponse } from "next/server"; -import { verifyMessage } from "viem"; +import { verifyMessage, type Address } from "viem"; import { createServiceRoleClient } from "../../../../../lib/supabase"; +import { publicClient } from "../../../../../lib/rpc"; +import { erc8004Abi, fetchTokenOrAgentURI, resolveAgentURI } from "../../../../../lib/contracts/erc8004"; +import { ERC8004_REGISTRY } from "../../../../../lib/contracts/constants"; /** * 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. * - * Body: { humanWallet, owsWallet, signature, humanSignature, agentId?, agentName?, agentDescription?, agentGenre? } + * Body: { humanWallet, owsWallet, signature, humanSignature, agentId } * - signature: OWS wallet proves it authorized this human as owner * - humanSignature: Human wallet proves it owns the address - * - agentId/agentName/agentDescription/agentGenre: pre-fetched from lookup-agent endpoint + * - agentId: from the lookup-agent step (server-verified against chain) * - * No RPC calls — all agent data comes pre-fetched from the frontend. + * Agent metadata is fetched server-side via tokenURI to prevent poisoning. */ export async function POST(request: NextRequest) { try { const body = await request.json(); - const { humanWallet, owsWallet, signature, humanSignature, agentId, agentName, agentDescription, agentGenre } = body; + const { humanWallet, owsWallet, signature, humanSignature, agentId } = body; if (!humanWallet || !owsWallet || !signature || !humanSignature) { return NextResponse.json( @@ -118,6 +121,53 @@ export async function POST(request: NextRequest) { } } + // Server-side agent metadata verification: + // If agentId provided, verify ownership on-chain and fetch canonical metadata + // from tokenURI. Never trust client-supplied name/description/genre. + const agentFields: { + agent_owner: string; + agent_type: string; + agent_id?: number; + agent_name?: string; + agent_description?: string; + agent_genre?: string; + agent_llm_model?: string; + agent_registered_at?: string; + } = { + agent_owner: normalizedHuman, + agent_type: "ows-writer", + }; + + if (agentId) { + const numericId = BigInt(String(agentId)); + try { + const owner = await publicClient.readContract({ + address: ERC8004_REGISTRY, + abi: erc8004Abi, + functionName: "ownerOf", + args: [numericId], + }); + + if ((owner as string).toLowerCase() === normalizedOws) { + agentFields.agent_id = Number(numericId); + + // Fetch canonical metadata from tokenURI + const uri = await fetchTokenOrAgentURI(numericId); + if (uri) { + const parsed = await resolveAgentURI(uri); + if (parsed.name) agentFields.agent_name = parsed.name as string; + if (parsed.description) agentFields.agent_description = parsed.description as string; + if (parsed.genre) agentFields.agent_genre = parsed.genre as string; + if (parsed.llmModel || parsed.model) agentFields.agent_llm_model = (parsed.llmModel || parsed.model) as string; + if (parsed.registeredAt) agentFields.agent_registered_at = parsed.registeredAt as string; + } + } + // If owner doesn't match, skip — don't trust unverified agentId + } catch { + // RPC failed — link still works, just without agent metadata + } + } + // Ensure the OWS wallet has a user row with agent metadata const { data: owsUser } = await supabase .from("users") @@ -126,15 +176,6 @@ export async function POST(request: NextRequest) { .limit(1) .single(); - 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(agentFields).eq("id", owsUser.id); diff --git a/src/components/AgentRegister.tsx b/src/components/AgentRegister.tsx index ecbacc0f..2baff5b3 100644 --- a/src/components/AgentRegister.tsx +++ b/src/components/AgentRegister.tsx @@ -136,9 +136,6 @@ function LinkAIWriter() { signature: bindingSignature, humanSignature, agentId: agentInfo.agentId, - agentName: agentInfo.name, - agentDescription: agentInfo.description, - agentGenre: agentInfo.genre, }), }); const data = await res.json(); From 033c192d77853fce4a228d557352fd9bb7f47d86 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Fri, 24 Apr 2026 15:39:44 +0900 Subject: [PATCH 4/4] [#993] Fail closed when agent verification fails in link-agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ownerOf/tokenURI failures now return 502 instead of silently proceeding. If agentId is provided, ownership and metadata must be verified — no silent fallthrough. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/user/link-agent/route.ts | 53 ++++++++++++++++++---------- 1 file changed, 34 insertions(+), 19 deletions(-) diff --git a/src/app/api/user/link-agent/route.ts b/src/app/api/user/link-agent/route.ts index 4673faa5..b182cfeb 100644 --- a/src/app/api/user/link-agent/route.ts +++ b/src/app/api/user/link-agent/route.ts @@ -140,32 +140,47 @@ export async function POST(request: NextRequest) { if (agentId) { const numericId = BigInt(String(agentId)); + + // Fail closed: if agentId is provided, ownership MUST be verified on-chain + let owner: string; try { - const owner = await publicClient.readContract({ + owner = (await publicClient.readContract({ address: ERC8004_REGISTRY, abi: erc8004Abi, functionName: "ownerOf", args: [numericId], - }); - - if ((owner as string).toLowerCase() === normalizedOws) { - agentFields.agent_id = Number(numericId); - - // Fetch canonical metadata from tokenURI - const uri = await fetchTokenOrAgentURI(numericId); - if (uri) { - const parsed = await resolveAgentURI(uri); - if (parsed.name) agentFields.agent_name = parsed.name as string; - if (parsed.description) agentFields.agent_description = parsed.description as string; - if (parsed.genre) agentFields.agent_genre = parsed.genre as string; - if (parsed.llmModel || parsed.model) agentFields.agent_llm_model = (parsed.llmModel || parsed.model) as string; - if (parsed.registeredAt) agentFields.agent_registered_at = parsed.registeredAt as string; - } - } - // If owner doesn't match, skip — don't trust unverified agentId + })) as string; } catch { - // RPC failed — link still works, just without agent metadata + return NextResponse.json( + { error: "Could not verify agent ownership on-chain. Please try again." }, + { status: 502 }, + ); + } + + if (owner.toLowerCase() !== normalizedOws) { + return NextResponse.json( + { error: "OWS wallet does not own the specified agent" }, + { status: 400 }, + ); } + + agentFields.agent_id = Number(numericId); + + // Fetch canonical metadata from tokenURI — fail if unavailable + const uri = await fetchTokenOrAgentURI(numericId); + if (!uri) { + return NextResponse.json( + { error: "Could not fetch agent metadata from chain. Please try again." }, + { status: 502 }, + ); + } + + const parsed = await resolveAgentURI(uri); + if (parsed.name) agentFields.agent_name = parsed.name as string; + if (parsed.description) agentFields.agent_description = parsed.description as string; + if (parsed.genre) agentFields.agent_genre = parsed.genre as string; + if (parsed.llmModel || parsed.model) agentFields.agent_llm_model = (parsed.llmModel || parsed.model) as string; + if (parsed.registeredAt) agentFields.agent_registered_at = parsed.registeredAt as string; } // Ensure the OWS wallet has a user row with agent metadata