diff --git a/lib/actions.ts b/lib/actions.ts index cf0a64d5..7198b216 100644 --- a/lib/actions.ts +++ b/lib/actions.ts @@ -102,6 +102,7 @@ export async function getFullUserProfile( fcProfile: FarcasterProfile | null; agentMeta: AgentMetadata | null; isAgentOwner: boolean; + linkedAgentMeta: AgentMetadata | null; }> { const dbUser = await getUserFromDB(address); const [fcProfile, agentMeta] = await Promise.all([ @@ -111,17 +112,25 @@ export async function getFullUserProfile( // Detect if this address is the agent OWNER (not the agent itself). // When true, the profile should display human identity, not agent identity. + // A human with linked_agent_wallet is an agent owner (DB-only OWS link). // Direct agents (agent_type='direct') are the agent themselves — isAgentOwner stays false. - // OWS-linked writers (agent_type='ows-writer') have a separate human owner — isAgentOwner is true. - // Legacy rows (agent_type=null) fall back to the old wallet-null heuristic for backward compat. + // Legacy rows with agent_type='ows-writer' also count as agent owners (backward compat). const normalized = address.toLowerCase(); + const hasLinkedAgent = dbUser?.linked_agent_wallet != null; const isOwner = agentMeta !== null && agentMeta.owner?.toLowerCase() === normalized; - const isAgentOwner = isOwner - && (dbUser?.agent_type === "ows-writer" - || (dbUser?.agent_type == null - && (dbUser?.agent_wallet == null || dbUser.agent_wallet.toLowerCase() !== normalized))); + const isAgentOwner = hasLinkedAgent + || (isOwner + && (dbUser?.agent_type === "ows-writer" + || (dbUser?.agent_type == null + && (dbUser?.agent_wallet == null || dbUser.agent_wallet.toLowerCase() !== normalized)))); + + // For agent owners with linked_agent_wallet, fetch the linked agent's metadata + let linkedAgentMeta: AgentMetadata | null = null; + if (hasLinkedAgent) { + linkedAgentMeta = await fetchAgentMetadata(dbUser!.linked_agent_wallet!); + } - return { dbUser, fcProfile, agentMeta, isAgentOwner }; + return { dbUser, fcProfile, agentMeta, isAgentOwner, linkedAgentMeta }; } /** @@ -286,11 +295,44 @@ export async function getAgentUserFromDB( * For a writer address, check if it's an ERC-8004 agent and return agent info * plus the owner's Farcaster profile (if available). * Returns null only if the address is NOT an agent. + * + * Also checks linked_agent_wallet reverse lookup: if a human has + * linked_agent_wallet pointing to this address, returns the human as owner. */ export async function getAgentOwnerProfile( writerAddress: string, ): Promise<{ ownerProfile: FarcasterProfile | null; agentName: string; agentId: number } | null> { "use server"; + const normalized = writerAddress.toLowerCase(); + + // Check if this address is someone's linked_agent_wallet (DB-only OWS link) + const supabase = createServiceRoleClient(); + if (supabase) { + const { data: linkedOwner } = await supabase + .from("users") + .select("*") + .eq("linked_agent_wallet", normalized) + .single(); + + if (linkedOwner) { + // This address IS an OWS agent wallet — look up the agent's own row for metadata. + // Only return if the agent is actually registered (has agent_id). + const agentUser = await getAgentUserFromDB(writerAddress); + if (agentUser?.agent_id) { + const ownerProfile = await getFarcasterProfile( + linkedOwner.primary_address || linkedOwner.verified_addresses?.[0] || "", + linkedOwner, + ); + return { + ownerProfile, + agentName: agentUser.agent_name || "AI Writer", + agentId: agentUser.agent_id, + }; + } + } + } + + // Legacy path: check agent columns directly const agentUser = await getAgentUserFromDB(writerAddress); if (!agentUser?.agent_id) return null; @@ -298,7 +340,6 @@ export async function getAgentOwnerProfile( // this address belongs to the human owner — not an agent. // Direct agents (agent_type='direct') are the agent themselves and should not be excluded. // Legacy rows (agent_type=null) fall back to the old wallet-null heuristic. - const normalized = writerAddress.toLowerCase(); const isOwnerAddress = agentUser.agent_owner?.toLowerCase() === normalized; const isAgentWallet = agentUser.agent_wallet?.toLowerCase() === normalized; const isLinkedOwner = isOwnerAddress diff --git a/lib/contracts/erc8004.ts b/lib/contracts/erc8004.ts index 532f9c21..3ad37faa 100644 --- a/lib/contracts/erc8004.ts +++ b/lib/contracts/erc8004.ts @@ -184,14 +184,15 @@ export async function detectWriterType( writerAddress: Address ): Promise { try { - // DB-first: check if this address is a cached agent wallet + // DB-first: check if this address is a cached agent wallet. + // Only check agent_wallet — human wallets with linked_agent_wallet are NOT agents. const supabase = createServiceRoleClient(); if (supabase) { const normalized = writerAddress.toLowerCase(); const { data } = await supabase .from("users") .select("agent_id") - .or(`agent_wallet.eq.${normalized},primary_address.eq.${normalized}`) + .eq("agent_wallet", normalized) .not("agent_id", "is", null) .limit(1) .single(); diff --git a/lib/supabase.ts b/lib/supabase.ts index 8d5170d9..3084f2c2 100644 --- a/lib/supabase.ts +++ b/lib/supabase.ts @@ -437,6 +437,7 @@ export interface Database { agent_owner: string | null; agent_type: string | null; agent_registered_at: string | null; + linked_agent_wallet: string | null; stats_fetched_at: string | null; steemhunt_fetched_at: string | null; created_at: string; @@ -481,6 +482,7 @@ export interface Database { agent_owner?: string | null; agent_type?: string | null; agent_registered_at?: string | null; + linked_agent_wallet?: string | null; stats_fetched_at?: string | null; steemhunt_fetched_at?: string | null; created_at?: string; @@ -525,6 +527,7 @@ export interface Database { agent_owner?: string | null; agent_type?: string | null; agent_registered_at?: string | null; + linked_agent_wallet?: string | null; stats_fetched_at?: string | null; steemhunt_fetched_at?: string | null; created_at?: string; diff --git a/package.json b/package.json index a61a0560..2309be32 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "plotlink", - "version": "0.1.24", + "version": "0.1.25", "private": true, "workspaces": [ "packages/*" diff --git a/src/app/api/user/link-agent/route.ts b/src/app/api/user/link-agent/route.ts new file mode 100644 index 00000000..62a5d6fa --- /dev/null +++ b/src/app/api/user/link-agent/route.ts @@ -0,0 +1,128 @@ +import { NextRequest, NextResponse } from "next/server"; +import { verifyMessage } from "viem"; +import { createServiceRoleClient } from "../../../../../lib/supabase"; + +/** + * 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. + * + * Body: { humanWallet, owsWallet, signature, humanSignature } + * - 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) + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { humanWallet, owsWallet, signature, humanSignature } = body; + + if (!humanWallet || !owsWallet || !signature || !humanSignature) { + return NextResponse.json( + { error: "humanWallet, owsWallet, signature, and humanSignature are required" }, + { status: 400 }, + ); + } + + if (!/^0x[a-fA-F0-9]{40}$/.test(humanWallet) || !/^0x[a-fA-F0-9]{40}$/.test(owsWallet)) { + return NextResponse.json( + { error: "Invalid wallet address format" }, + { status: 400 }, + ); + } + + // Verify binding proof: OWS wallet signed "I authorize {humanWallet} as my PlotLink owner" + const owsMessage = `I authorize ${humanWallet} as my PlotLink owner. Wallet: ${owsWallet}`; + const owsValid = await verifyMessage({ + address: owsWallet as `0x${string}`, + message: owsMessage, + signature: signature as `0x${string}`, + }); + + if (!owsValid) { + return NextResponse.json( + { error: "OWS binding signature is invalid" }, + { status: 400 }, + ); + } + + // Verify caller owns humanWallet + const humanMessage = `I am linking OWS wallet ${owsWallet} to my PlotLink account. Wallet: ${humanWallet}`; + const humanValid = await verifyMessage({ + address: humanWallet as `0x${string}`, + message: humanMessage, + signature: humanSignature as `0x${string}`, + }); + + if (!humanValid) { + return NextResponse.json( + { error: "Human wallet ownership signature is invalid" }, + { status: 400 }, + ); + } + + const supabase = createServiceRoleClient(); + if (!supabase) { + return NextResponse.json({ error: "Database not configured" }, { status: 500 }); + } + + const normalizedHuman = humanWallet.toLowerCase(); + const normalizedOws = owsWallet.toLowerCase(); + + // Ensure this OWS wallet isn't already linked to another human + const { data: existingLink } = await supabase + .from("users") + .select("id, primary_address") + .eq("linked_agent_wallet", normalizedOws) + .single(); + + if (existingLink && existingLink.primary_address !== normalizedHuman) { + return NextResponse.json( + { error: "This OWS wallet is already linked to another account" }, + { status: 409 }, + ); + } + + // Find human's user row + const { data: byVerified } = await supabase + .from("users") + .select("id") + .contains("verified_addresses", [normalizedHuman]) + .single(); + + const { data: byPrimary } = !byVerified + ? await supabase.from("users").select("id").eq("primary_address", normalizedHuman).single() + : { data: byVerified }; + + const existingUser = byVerified ?? byPrimary; + + if (existingUser) { + const { error: updateError } = await supabase + .from("users") + .update({ linked_agent_wallet: normalizedOws }) + .eq("id", existingUser.id); + + if (updateError) { + 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, + }); + + if (insertError) { + return NextResponse.json({ error: insertError.message }, { status: 500 }); + } + } + + return NextResponse.json({ ok: true, linkedWallet: normalizedOws }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Internal error" }, + { status: 500 }, + ); + } +} diff --git a/src/app/profile/[address]/page.tsx b/src/app/profile/[address]/page.tsx index a2e72c46..8ade3a3a 100644 --- a/src/app/profile/[address]/page.tsx +++ b/src/app/profile/[address]/page.tsx @@ -53,6 +53,7 @@ export default function ProfilePage() { const agentLoading = profileLoading; // Owner of an agent is not an agent themselves — show human identity const isAgentOwner = fullProfile?.isAgentOwner ?? false; + const linkedAgentMeta = fullProfile?.linkedAgentMeta ?? null; const isAgent = !profileLoading && agentMeta !== null && !isAgentOwner; // Cumulative claimed royalties (on-chain) @@ -149,6 +150,7 @@ export default function ProfilePage() { agentLoading={agentLoading} isAgent={isAgent} isAgentOwner={isAgentOwner} + linkedAgentMeta={linkedAgentMeta} claimedRoyalties={claimedRoyalties ?? null} plotBalance={plotBalance ?? null} plotUsdPrice={plotUsdPrice ?? null} @@ -213,6 +215,7 @@ function ProfileHeader({ agentLoading, isAgent, isAgentOwner, + linkedAgentMeta, claimedRoyalties, plotBalance, plotUsdPrice, @@ -231,6 +234,7 @@ function ProfileHeader({ agentLoading: boolean; isAgent: boolean; isAgentOwner: boolean; + linkedAgentMeta: AgentMetadata | null; claimedRoyalties: bigint | null; plotBalance: bigint | null; plotUsdPrice: number | null; @@ -411,13 +415,11 @@ function ProfileHeader({ )} - {/* Agent Identity card — shown for registered agents and agent owners */} - {(isAgent || isAgentOwner) && agentMeta && ( + {/* Agent Identity card — shown for registered agents */} + {isAgent && agentMeta && (
- - {isAgentOwner ? "Linked AI Writer" : "Agent Identity"} - + Agent Identity ERC-8004
@@ -470,6 +472,52 @@ function ProfileHeader({
)} + {/* Linked AI Writer card — shown for human agent owners */} + {isAgentOwner && ( +
+
+ Linked AI Writer + {linkedAgentMeta?.agentId && ( + ERC-8004 + )} +
+
+ {linkedAgentMeta ? ( + <> + {linkedAgentMeta.agentId && ( +
+ Agent ID: + {linkedAgentMeta.agentId} +
+ )} +
+ Name: + {linkedAgentMeta.name} +
+ {linkedAgentMeta.llmModel && ( +
+ Model: + {linkedAgentMeta.llmModel} +
+ )} + + ) : ( +
+ OWS wallet linked. Agent will appear here once registered on-chain. +
+ )} + {dbUser?.linked_agent_wallet && ( +
+ Wallet: + + {truncateAddress(dbUser.linked_agent_wallet)} + +
+ )} +
+
+ )} + {/* Wallet identity card — always shown */}
Wallet diff --git a/src/components/AgentRegister.tsx b/src/components/AgentRegister.tsx index e10a07b4..bdf123d5 100644 --- a/src/components/AgentRegister.tsx +++ b/src/components/AgentRegister.tsx @@ -1,12 +1,13 @@ "use client"; import { useState, useMemo } from "react"; -import { useAccount, useWriteContract, useSignTypedData } from "wagmi"; +import { useAccount, useWriteContract, useSignTypedData, useSignMessage } from "wagmi"; import { decodeEventLog, type Hex } from "viem"; import { browserClient as publicClient } from "../../lib/rpc"; import { erc8004Abi } from "../../lib/contracts/erc8004"; import { ERC8004_REGISTRY, BASE_CHAIN_ID, EXPLORER_URL } from "../../lib/contracts/constants"; import { Select } from "./Select"; +import { truncateAddress } from "../../lib/utils"; const GENRES = [ "Fantasy", "Sci-Fi", "Mystery", "Romance", "Horror", @@ -38,195 +39,67 @@ const SET_WALLET_TYPES = { } as const; /* ───────────────────────────────────────────────────────────────────────────── - * Link AI Writer — OWS binding verification + registration + * Link AI Writer — DB-only OWS binding (no on-chain registration on human side) + * ERC-8004 registration happens on the OWS wallet via plotlink-ows. * ───────────────────────────────────────────────────────────────────────────── */ function LinkAIWriter() { const { address } = useAccount(); - const { writeContractAsync } = useWriteContract(); + const { signMessageAsync } = useSignMessage(); const [owsWallet, setOwsWallet] = useState(""); const [bindingSignature, setBindingSignature] = useState(""); - const [verifying, setVerifying] = useState(false); - const [verified, setVerified] = useState(false); const [linking, setLinking] = useState(false); - const [linkTxHash, setLinkTxHash] = useState(); - const [linkedAgentId, setLinkedAgentId] = useState(); const [done, setDone] = useState(false); const [error, setError] = useState(null); - // Wallet bind step (after registration) - const [walletBindSig, setWalletBindSig] = useState(""); - const [walletBindDeadline, setWalletBindDeadline] = useState(""); - const [bindingWallet, setBindingWallet] = useState(false); - const [bindTxHash, setBindTxHash] = useState(); - const [walletBound, setWalletBound] = useState(false); - const validInputs = /^0x[a-fA-F0-9]{40}$/.test(owsWallet) && bindingSignature.startsWith("0x") && bindingSignature.length > 10; - async function handleVerifyAndLink() { + async function handleLink() { if (!address) return; try { setError(null); - setVerifying(true); - - // Step 1: Verify binding signature - const verifyRes = await fetch("/api/user/verify-ows-binding", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ owsWallet, humanWallet: address, signature: bindingSignature }), - }); - const verifyData = await verifyRes.json(); - if (!verifyRes.ok || !verifyData.valid) { - throw new Error(verifyData.error || "Invalid binding signature"); - } - setVerified(true); - setVerifying(false); - - // Step 2: Register on-chain (human wallet signs as owner) setLinking(true); - const agentURI = JSON.stringify({ - name: `AI Writer`, - description: "AI fiction writer linked via PlotLink OWS", - type: "ows-writer", - owsWallet, - linkedBy: address, - registeredAt: new Date().toISOString(), - }); - const hash = await writeContractAsync({ - address: ERC8004_REGISTRY, - abi: erc8004Abi, - functionName: "register", - args: [agentURI], - }); - setLinkTxHash(hash); - const receipt = await publicClient.waitForTransactionReceipt({ hash }); + // 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 }); - let newAgentId: bigint | undefined; - const registeredLog = receipt.logs.find((log) => { - try { - const decoded = decodeEventLog({ abi: erc8004Abi, data: log.data, topics: log.topics }); - return decoded.eventName === "Registered"; - } catch { return false; } - }); - if (registeredLog) { - const decoded = decodeEventLog({ abi: erc8004Abi, data: registeredLog.data, topics: registeredLog.topics }); - if (decoded.eventName === "Registered") { - newAgentId = decoded.args.agentId; - setLinkedAgentId(newAgentId); - } - } - - // Step 3: Persist to DB (agent_wallet deferred until setAgentWallet succeeds on-chain) - const cacheRes = await fetch("/api/user/agent-register", { + // 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" }, body: JSON.stringify({ - walletAddress: address, - agentId: newAgentId?.toString(), - name: "AI Writer", - description: "AI fiction writer linked via PlotLink OWS", - agentOwner: address, - agentType: "ows-writer", + humanWallet: address, + owsWallet, + signature: bindingSignature, + humanSignature, }), }); - if (!cacheRes.ok) { - setError("On-chain registration succeeded, but DB cache failed — will sync on next visit"); + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || "Linking failed"); } setDone(true); } catch (err) { setError(err instanceof Error ? err.message : "Linking failed"); } finally { - setVerifying(false); setLinking(false); } } - async function handleWalletBind() { - if (!linkedAgentId || !walletBindSig || !walletBindDeadline || !address) return; - try { - setError(null); - setBindingWallet(true); - const hash = await writeContractAsync({ - address: ERC8004_REGISTRY, - abi: erc8004Abi, - functionName: "setAgentWallet", - args: [linkedAgentId, owsWallet as `0x${string}`, BigInt(walletBindDeadline), walletBindSig as Hex], - }); - setBindTxHash(hash); - await publicClient.waitForTransactionReceipt({ hash }); - // Persist wallet binding to DB - fetch("/api/user/agent-update", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - walletAddress: address, - fields: { agent_wallet: owsWallet.toLowerCase() }, - }), - }).catch(() => {}); - setWalletBound(true); - } catch (err) { - setError(err instanceof Error ? err.message : "Wallet binding failed"); - } finally { - setBindingWallet(false); - } - } - if (done) { return (
-

- {walletBound ? "Linked! AI writer registered and wallet bound on-chain." : "Registered! Now bind your OWS wallet."} -

- {linkedAgentId !== undefined &&

Agent ID: {linkedAgentId.toString()}

} -

OWS wallet: {owsWallet.slice(0, 6)}...{owsWallet.slice(-4)}

+

Linked! Your AI writer is connected to your account.

+

OWS wallet: {truncateAddress(owsWallet)}

- {linkTxHash && ( - - )} - - {/* Wallet bind step */} - {!walletBound && linkedAgentId !== undefined && ( -
-

Complete wallet binding

-

- Go to your OWS app → Settings → enter Agent ID {linkedAgentId.toString()} → click "Generate Wallet Bind Code". Paste the signature and deadline below. -

-
- - setWalletBindSig(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" /> -
-
- - setWalletBindDeadline(e.target.value)} placeholder="e.g. 1712345678" - 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" /> -
- -
- )} - - {bindTxHash && ( - - )} - - {error && ( -
{error}
- )} +

+ Your OWS writer will register itself on-chain via ERC-8004 automatically. + Your profile will show "Operates: AI Writer" once the agent is registered. +

); } @@ -240,7 +113,7 @@ function LinkAIWriter() {

1. Open PlotLink OWS app → Settings → "Link to PlotLink"

2. Enter your PlotLink wallet address → app generates a binding code

-

3. Paste the binding code below

+

3. Paste the OWS wallet address and binding code below

@@ -258,23 +131,9 @@ function LinkAIWriter() {
{error}
)} - {verified && !linking && ( -
- Signature verified. Registering on-chain... -
- )} - - {linkTxHash && !done && ( - - )} - -
); diff --git a/supabase/migrations/00033_users_linked_agent_wallet.sql b/supabase/migrations/00033_users_linked_agent_wallet.sql new file mode 100644 index 00000000..03e5186a --- /dev/null +++ b/supabase/migrations/00033_users_linked_agent_wallet.sql @@ -0,0 +1,6 @@ +-- Add linked_agent_wallet column for DB-only human ↔ OWS agent link. +-- Human rows store the OWS wallet address here; no agent_* fields needed. +ALTER TABLE users ADD COLUMN IF NOT EXISTS linked_agent_wallet TEXT; + +CREATE INDEX IF NOT EXISTS idx_users_linked_agent_wallet + ON users (linked_agent_wallet) WHERE linked_agent_wallet IS NOT NULL; diff --git a/supabase/migrations/00034_migrate_ows_to_linked_agent_wallet.sql b/supabase/migrations/00034_migrate_ows_to_linked_agent_wallet.sql new file mode 100644 index 00000000..e8d8dc6c --- /dev/null +++ b/supabase/migrations/00034_migrate_ows_to_linked_agent_wallet.sql @@ -0,0 +1,25 @@ +-- Migrate existing OWS-linked agent rows to use linked_agent_wallet. +-- For rows where agent_type='ows-writer': move agent_wallet value to +-- linked_agent_wallet, then clear agent_* fields so the human row +-- no longer appears as an agent. + +-- Step 1: Copy agent_wallet to linked_agent_wallet for OWS-linked owners +UPDATE users +SET linked_agent_wallet = agent_wallet +WHERE agent_type = 'ows-writer' + AND agent_wallet IS NOT NULL + AND linked_agent_wallet IS NULL; + +-- Step 2: Clear agent fields from OWS-linked owner rows +-- These fields now belong on the OWS wallet's own row (managed by plotlink-ows) +UPDATE users +SET agent_id = NULL, + agent_name = NULL, + agent_description = NULL, + agent_genre = NULL, + agent_llm_model = NULL, + agent_wallet = NULL, + agent_owner = NULL, + agent_type = NULL, + agent_registered_at = NULL +WHERE agent_type = 'ows-writer';