diff --git a/package.json b/package.json index 915bba2b..95341c3d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "plotlink", - "version": "0.1.15", + "version": "0.1.16", "private": true, "workspaces": [ "packages/*" diff --git a/src/components/AgentDashboard.tsx b/src/components/AgentDashboard.tsx index 955f31f5..2badcb3e 100644 --- a/src/components/AgentDashboard.tsx +++ b/src/components/AgentDashboard.tsx @@ -1,127 +1,193 @@ "use client"; -import { useEffect, useRef } from "react"; -import { useAccount, useReadContract } from "wagmi"; +import { useMemo } from "react"; +import { useAccount, useReadContract, useReadContracts } from "wagmi"; import { useQuery } from "@tanstack/react-query"; import Link from "next/link"; import { erc8004Abi } from "../../lib/contracts/erc8004"; -import { ERC8004_REGISTRY } from "../../lib/contracts/constants"; -import { getAgentUserFromDB, checkUserExists, cacheAgentById } from "../../lib/actions"; +import { mcv2BondAbi } from "../../lib/price"; +import { ERC8004_REGISTRY, MCV2_BOND, PLOT_TOKEN } from "../../lib/contracts/constants"; +import { browserClient } from "../../lib/rpc"; +import { formatUnits } from "viem"; -export function AgentDashboard() { - const { address } = useAccount(); - - // DB-first: check cached agent data - const { data: dbUser, isLoading: dbLoading } = useQuery({ - queryKey: ["db-user-dashboard", address], - queryFn: () => getAgentUserFromDB(address!), - enabled: !!address, - }); +const ZERO_ADDR = "0x0000000000000000000000000000000000000000"; - const dbAgentId = dbUser?.agent_id; - const dbDetected = dbAgentId != null; - const dbIsOwner = dbDetected && dbUser?.agent_owner?.toLowerCase() === address?.toLowerCase(); - const dbIsAgentWallet = dbDetected && dbUser?.agent_wallet?.toLowerCase() === address?.toLowerCase(); - const dbAgentWallet = dbUser?.agent_wallet; - - // Check if user exists in DB at all (even without agent_id) - const { data: userExists, isLoading: userExistsLoading } = useQuery({ - queryKey: ["user-exists-dashboard", address], - queryFn: () => checkUserExists(address!), - enabled: !!address && !dbLoading && !dbDetected, - }); +interface AgentInfo { + agentId: bigint; + name: string; + agentWallet?: string; + source: "ows" | "direct"; +} - // RPC fallback: only for completely unknown wallets (no DB record at all) - // Known users with agent_id=NULL are definitively non-agents — zero RPC calls - const needsRpcFallback = !dbLoading && !dbDetected && !userExistsLoading && userExists === false && !!address; +export function AgentDashboard() { + const { address } = useAccount(); - const { data: rpcAgentId, isLoading: rpcWalletLoading } = useReadContract({ + // Step 1: Get number of agent NFTs owned by connected wallet + const { data: balance, isLoading: balanceLoading } = useReadContract({ address: ERC8004_REGISTRY, abi: erc8004Abi, - functionName: "agentIdByWallet", + functionName: "balanceOf", args: address ? [address] : undefined, - query: { enabled: needsRpcFallback }, + query: { enabled: !!address }, }); - const { data: rpcBalance, isLoading: rpcBalanceLoading } = useReadContract({ - address: ERC8004_REGISTRY, - abi: erc8004Abi, - functionName: "balanceOf", - args: address ? [address] : undefined, - query: { enabled: needsRpcFallback }, + const agentCount = balance !== undefined ? Number(balance) : 0; + + // Step 2: Enumerate all owned agent IDs + const tokenIndexCalls = useMemo(() => { + if (!address || agentCount === 0) return []; + return Array.from({ length: agentCount }, (_, i) => ({ + address: ERC8004_REGISTRY, + abi: erc8004Abi, + functionName: "tokenOfOwnerByIndex" as const, + args: [address, BigInt(i)] as const, + })); + }, [address, agentCount]); + + const { data: tokenResults, isLoading: tokensLoading } = useReadContracts({ + contracts: tokenIndexCalls, + query: { enabled: tokenIndexCalls.length > 0 }, }); - const rpcHasNft = rpcBalance !== undefined && rpcBalance > BigInt(0); - const { data: rpcOwnedToken, isLoading: rpcTokenLoading } = useReadContract({ - address: ERC8004_REGISTRY, - abi: erc8004Abi, - functionName: "tokenOfOwnerByIndex", - args: address ? [address, BigInt(0)] : undefined, - query: { enabled: needsRpcFallback && rpcHasNft }, + const agentIds = useMemo(() => { + if (!tokenResults) return []; + return tokenResults + .filter((r) => r.status === "success" && r.result !== undefined) + .map((r) => r.result as bigint); + }, [tokenResults]); + + // Step 3: Fetch metadata (agentURI + getAgentWallet) for each agent + const metadataCalls = useMemo(() => { + if (agentIds.length === 0) return []; + return agentIds.flatMap((id) => [ + { + address: ERC8004_REGISTRY, + abi: erc8004Abi, + functionName: "agentURI" as const, + args: [id] as const, + }, + { + address: ERC8004_REGISTRY, + abi: erc8004Abi, + functionName: "getAgentWallet" as const, + args: [id] as const, + }, + ]); + }, [agentIds]); + + const { data: metadataResults, isLoading: metadataLoading } = useReadContracts({ + contracts: metadataCalls, + query: { enabled: metadataCalls.length > 0 }, }); - const rpcIsOwner = rpcHasNft && rpcOwnedToken !== undefined; - const { data: rpcBoundWallet } = useReadContract({ + // Parse agent info from contract results + const agents: AgentInfo[] = useMemo(() => { + if (agentIds.length === 0 || !metadataResults) return []; + return agentIds.map((id, i) => { + const uriResult = metadataResults[i * 2]; + const walletResult = metadataResults[i * 2 + 1]; + + let name = `Agent #${id.toString()}`; + let source: "ows" | "direct" = "direct"; + if (uriResult?.status === "success" && uriResult.result) { + try { + const meta = JSON.parse(uriResult.result as string); + if (meta.name) name = meta.name; + if (meta.type === "ows-writer" || meta.owsWallet) source = "ows"; + } catch { /* not JSON */ } + } + + const walletAddr = walletResult?.status === "success" ? (walletResult.result as string) : undefined; + const agentWallet = walletAddr && walletAddr !== ZERO_ADDR ? walletAddr : undefined; + + return { agentId: id, name, agentWallet, source }; + }); + }, [agentIds, metadataResults]); + + // Also check if connected wallet itself is an agent wallet (not owner) + const { data: selfAgentId } = useReadContract({ address: ERC8004_REGISTRY, abi: erc8004Abi, - functionName: "getAgentWallet", - args: rpcOwnedToken !== undefined ? [rpcOwnedToken] : undefined, - query: { enabled: needsRpcFallback && rpcIsOwner }, + functionName: "agentIdByWallet", + args: address ? [address] : undefined, + query: { enabled: !!address }, }); - const rpcIsAgentWallet = rpcAgentId !== undefined && rpcAgentId > BigInt(0); - - // Combine DB + RPC - let agentId: bigint | undefined; - let isOwner = false; - let isAgentWallet = false; - let writerAddress: string | undefined = address; - - if (dbDetected) { - agentId = BigInt(dbAgentId!); - isOwner = dbIsOwner; - isAgentWallet = dbIsAgentWallet; - // For owner, use cached agent_wallet for storyline lookup - if (dbIsOwner && dbAgentWallet) { - writerAddress = dbAgentWallet; - } - } else if (rpcIsOwner) { - agentId = rpcOwnedToken; - isOwner = true; - const hasValidRpcWallet = rpcBoundWallet && rpcBoundWallet !== "0x0000000000000000000000000000000000000000"; - if (hasValidRpcWallet) writerAddress = rpcBoundWallet as string; - } else if (rpcIsAgentWallet) { - agentId = rpcAgentId; - isAgentWallet = true; - } + const isSelfAgent = selfAgentId !== undefined && selfAgentId > BigInt(0); + const selfAlreadyInList = agents.some( + (a) => a.agentWallet?.toLowerCase() === address?.toLowerCase() || a.agentId === selfAgentId, + ); - const isAgent = agentId !== undefined; - const detectLoading = dbLoading || (!dbDetected && userExistsLoading) || (needsRpcFallback && (rpcWalletLoading || rpcBalanceLoading || (rpcHasNft && rpcTokenLoading))); + // Fetch storylines for each agent's writer address + // Direct agents use owner wallet implicitly; OWS agents need an explicit bound wallet + const writerAddresses = useMemo(() => { + const addrs: string[] = []; + for (const agent of agents) { + if (agent.agentWallet) { + addrs.push(agent.agentWallet); + } else if (agent.source === "direct" && address) { + addrs.push(address); + } + } + if (isSelfAgent && !selfAlreadyInList && address) { + addrs.push(address); + } + return [...new Set(addrs.filter(Boolean))]; + }, [agents, isSelfAgent, selfAlreadyInList, address]); - // Auto-cache: when RPC fallback detects an agent not in DB, persist it - const cachedRef = useRef(false); - useEffect(() => { - if (!dbDetected && isAgent && address && agentId && !cachedRef.current) { - cachedRef.current = true; - cacheAgentById(address, agentId.toString()).catch(() => - cacheAgentById(address, agentId.toString()).catch(() => {}), + const { data: allStorylines, isLoading: storylinesLoading } = useQuery({ + queryKey: ["all-agent-storylines", writerAddresses], + queryFn: async () => { + const results: Record> = {}; + await Promise.all( + writerAddresses.map(async (addr) => { + try { + const res = await fetch(`/api/storyline/by-writer?writer=${addr}&type=agent`); + if (res.ok) results[addr.toLowerCase()] = await res.json(); + else results[addr.toLowerCase()] = []; + } catch { results[addr.toLowerCase()] = []; } + }), ); - } - }, [dbDetected, isAgent, address, agentId]); + return results; + }, + enabled: writerAddresses.length > 0, + }); - // Fetch agent's storylines from Supabase - const { data: storylines, isLoading: storylinesLoading } = useQuery({ - queryKey: ["agent-storylines", writerAddress], + // Fetch royalties for each agent's writer address + const { data: royalties } = useQuery({ + queryKey: ["agent-royalties", writerAddresses], queryFn: async () => { - if (!writerAddress) return []; - const res = await fetch(`/api/storyline/by-writer?writer=${writerAddress}&type=agent`); - if (!res.ok) return []; - return res.json() as Promise>; + const results: Record = {}; + await Promise.all( + writerAddresses.map(async (addr) => { + try { + const [balance, claimed] = await browserClient.readContract({ + address: MCV2_BOND, + abi: mcv2BondAbi, + functionName: "getRoyaltyInfo", + args: [addr as `0x${string}`, PLOT_TOKEN], + }); + results[addr.toLowerCase()] = { unclaimed: balance as bigint, claimed: claimed as bigint }; + } catch { results[addr.toLowerCase()] = { unclaimed: BigInt(0), claimed: BigInt(0) }; } + }), + ); + return results; }, - enabled: !!writerAddress && isAgent, + enabled: writerAddresses.length > 0, + }); + + // Fetch PLOT token decimals dynamically + const { data: plotDecimals } = useReadContract({ + address: PLOT_TOKEN, + abi: [{ type: "function", name: "decimals", stateMutability: "view", inputs: [], outputs: [{ name: "", type: "uint8" }] }] as const, + functionName: "decimals", + query: { staleTime: Infinity }, }); + const decimals = plotDecimals !== undefined ? Number(plotDecimals) : 18; - if (detectLoading) { + const isLoading = balanceLoading || tokensLoading || metadataLoading; + + if (isLoading) { return (

Loading agent status...

@@ -129,63 +195,130 @@ export function AgentDashboard() { ); } - if (!isAgent) { + const hasAgents = agents.length > 0 || (isSelfAgent && !selfAlreadyInList); + + if (!hasAgents) { return (
-

This wallet is not registered as an agent.

+

You have no AI agents registered.

- Switch to the Register tab to register your agent. + Switch to the Register tab to register an agent or link an OWS Writer.

); } + // Aggregate stats + const allAgentStorylines = Object.values(allStorylines || {}).flat(); + const totalStories = allAgentStorylines.length; + const totalRoyalties = Object.values(royalties || {}).reduce( + (acc, r) => ({ unclaimed: acc.unclaimed + r.unclaimed, claimed: acc.claimed + r.claimed }), + { unclaimed: BigInt(0), claimed: BigInt(0) }, + ); + const totalEarned = totalRoyalties.unclaimed + totalRoyalties.claimed; + + // Build full agent list (owned + self-as-agent-wallet) + const displayAgents = [...agents]; + if (isSelfAgent && !selfAlreadyInList) { + displayAgents.push({ + agentId: selfAgentId, + name: "This Wallet (Agent)", + agentWallet: address, + source: "direct", + }); + } + return (
-
-

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)} -

-
+ {/* Aggregate stats */} + {displayAgents.length > 1 && ( +
+

{displayAgents.length} Agents

+

+ {totalStories} storyline{totalStories !== 1 ? "s" : ""} published + {totalEarned > BigInt(0) && <> · {formatUnits(totalEarned, decimals)} PLOT earned} +

+
+ )} -

Your Storylines

+ {/* Agent cards */} +
+ {displayAgents.map((agent) => { + // Direct agents use owner wallet; OWS agents need explicit binding + const hasActivity = agent.agentWallet || agent.source === "direct"; + const writerAddr = agent.agentWallet || (agent.source === "direct" ? address : "") || ""; + const storylines = hasActivity ? (allStorylines?.[writerAddr.toLowerCase()] || []) : []; - {storylinesLoading ? ( -

Loading storylines...

- ) : !storylines || storylines.length === 0 ? ( -
-

No storylines yet.

- - Create your first storyline - -
- ) : ( -
- {storylines.map((s) => ( - -
-

{s.title}

-

- {s.plot_count} plot{s.plot_count !== 1 ? "s" : ""} -

+ return ( +
+
+
+

{agent.name}

+ #{agent.agentId.toString()} + + {agent.source === "ows" ? "OWS Writer" : "Direct"} + +
+ + Profile +
- #{s.storyline_id} - - ))} -
- )} + + {agent.agentWallet ? ( +

+ Wallet: {agent.agentWallet.slice(0, 6)}...{agent.agentWallet.slice(-4)} +

+ ) : agent.source === "ows" ? ( +

+ No wallet bound — complete binding in the Manage tab to see activity +

+ ) : null} + + {hasActivity && (() => { + const agentRoyalty = royalties?.[writerAddr.toLowerCase()]; + const earned = agentRoyalty ? agentRoyalty.unclaimed + agentRoyalty.claimed : BigInt(0); + return ( +
+ {storylines.length} storyline{storylines.length !== 1 ? "s" : ""} + {earned > BigInt(0) && {formatUnits(earned, decimals)} PLOT earned} +
+ ); + })()} + + {!hasActivity ? null : storylinesLoading ? ( +

Loading...

+ ) : storylines.length === 0 ? ( +

No storylines yet

+ ) : ( +
+ {storylines.map((s) => ( + +
+

{s.title}

+

+ {s.plot_count} plot{s.plot_count !== 1 ? "s" : ""} +

+
+ #{s.storyline_id} + + ))} +
+ )} +
+ ); + })} +
); }