Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 40 additions & 13 deletions lib/contracts/erc8004.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,22 @@ 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",
stateMutability: "view",
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",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -350,12 +353,7 @@ export async function getAgentMetadataById(
): Promise<AgentMetadata | null> {
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,
Expand Down Expand Up @@ -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.
*/
export async function fetchTokenOrAgentURI(agentId: bigint): Promise<string | null> {
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;
}
}
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "plotlink",
"version": "1.0.2",
"version": "1.1.0",
"private": true,
"workspaces": [
"packages/*"
Expand Down
186 changes: 74 additions & 112 deletions src/app/api/user/link-agent/route.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,26 @@
import { NextRequest, NextResponse } from "next/server";
import { verifyMessage } from "viem";
import { verifyMessage, type Address } from "viem";

Check warning on line 2 in src/app/api/user/link-agent/route.ts

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

'Address' is defined but never used
import { createServiceRoleClient } from "../../../../../lib/supabase";
import { getAgentMetadata } from "../../../../../lib/contracts/erc8004";
import type { Address } from "viem";
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. 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 }
* - 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: from the lookup-agent step (server-verified against chain)
*
* 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: providedAgentId, agentName, agentDescription, agentGenre } = body;
const { humanWallet, owsWallet, signature, humanSignature, agentId } = body;

if (!humanWallet || !owsWallet || !signature || !humanSignature) {
return NextResponse.json(
Expand Down Expand Up @@ -109,7 +111,6 @@
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,
Expand All @@ -120,122 +121,83 @@
}
}

// 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.
const { data: owsUser } = await supabase
.from("users")
.select("id")
.or(`primary_address.eq.${normalizedOws},agent_wallet.eq.${normalizedOws}`)
.limit(1)
.single();

// Build agent fields — use provided agentId if available, try RPC as fallback
let agentFields: Record<string, unknown> = {};
const agentId = providedAgentId ? Number(providedAgentId) : null;
// 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) {
// Agent ID provided by client — verify on-chain that the OWS wallet owns this NFT
const numericId = BigInt(String(agentId));

// Fail closed: if agentId is provided, ownership MUST be verified on-chain
let owner: string;
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,
owner = (await publicClient.readContract({
address: ERC8004_REGISTRY,
abi: erc8004Abi,
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 */ }
args: [numericId],
})) as string;
} catch {
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;
}

// 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;
// Ensure the OWS wallet has a user row with agent metadata
const { data: owsUser } = await supabase
.from("users")
.select("id")
.or(`primary_address.eq.${normalizedOws},agent_wallet.eq.${normalizedOws}`)
.limit(1)
.single();

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,
});
}
Expand Down
Loading
Loading