diff --git a/lib/actions.ts b/lib/actions.ts index 2ea2590a..ccfcd734 100644 --- a/lib/actions.ts +++ b/lib/actions.ts @@ -3,6 +3,7 @@ import { lookupByAddress, type FarcasterProfile } from "./farcaster"; import { getAgentMetadata as _getAgentMetadata, + getAgentMetadataById as _getAgentMetadataById, type AgentMetadata, } from "./contracts/erc8004"; import { createServiceRoleClient, type User } from "./supabase"; @@ -32,16 +33,109 @@ export async function getFarcasterProfile( /** * Server action that resolves ERC-8004 agent metadata from a wallet address. + * Checks DB cache first, falls back to RPC. Caches externally registered agents. */ export async function fetchAgentMetadata( address: string, ): Promise { - return _getAgentMetadata(address as Address); + // DB-first: check cached agent data (agent-specific lookup) + const dbUser = await getAgentUserFromDB(address); + if (dbUser?.agent_id != null) { + return { + agentId: String(dbUser.agent_id), + owner: dbUser.agent_owner ?? undefined, + name: dbUser.agent_name ?? "Unknown Agent", + description: dbUser.agent_description ?? "", + genre: dbUser.agent_genre ?? undefined, + llmModel: dbUser.agent_llm_model ?? undefined, + registeredAt: dbUser.agent_registered_at ?? undefined, + }; + } + + // RPC fallback — also cache the result for next time + const meta = await _getAgentMetadata(address as Address); + if (meta && meta.agentId) { + const supabase = createServiceRoleClient(); + if (supabase) { + const normalized = address.toLowerCase(); + const userId = dbUser?.id; + const agentFields = { + agent_id: Number(meta.agentId), + agent_name: meta.name || null, + agent_description: meta.description || null, + agent_genre: meta.genre || null, + agent_llm_model: meta.llmModel || null, + agent_owner: meta.owner?.toLowerCase() || null, + agent_registered_at: meta.registeredAt || null, + agent_wallet: normalized, + }; + try { + if (userId) { + await supabase.from("users").update(agentFields).eq("id", userId); + } else { + await supabase.from("users").insert({ primary_address: normalized, ...agentFields }); + } + } catch { + // Best-effort cache — don't fail the metadata lookup + } + } + } + return meta; +} + +/** + * Cache an externally registered agent by agentId. + * Use when the wallet is an NFT owner (not the bound agent wallet), + * so agentIdByWallet() wouldn't find it. + */ +export async function cacheAgentById( + walletAddress: string, + agentId: string, +): Promise { + const supabase = createServiceRoleClient(); + if (!supabase) return; + + const normalized = walletAddress.toLowerCase(); + + // Check if already cached + const { data: existing } = await supabase + .from("users") + .select("agent_id") + .eq("agent_id", Number(agentId)) + .single(); + if (existing) return; + + // Fetch metadata from RPC by agentId + const meta = await _getAgentMetadataById(BigInt(agentId)); + if (!meta) return; + + const agentFields = { + agent_id: Number(meta.agentId), + agent_name: meta.name || null, + agent_description: meta.description || null, + agent_genre: meta.genre || null, + agent_llm_model: meta.llmModel || null, + agent_owner: meta.owner?.toLowerCase() || normalized, + agent_wallet: meta.agentWallet && meta.agentWallet !== "0x0000000000000000000000000000000000000000" ? meta.agentWallet.toLowerCase() : null, + agent_registered_at: meta.registeredAt || null, + }; + + // Find existing user row or create one + const dbUser = await getUserFromDB(walletAddress); + try { + if (dbUser) { + await supabase.from("users").update(agentFields).eq("id", dbUser.id); + } else { + await supabase.from("users").insert({ primary_address: normalized, ...agentFields }); + } + } catch { + // Best-effort + } } /** * Look up a user from the DB by wallet address. - * Searches verified_addresses first, then primary_address for wallet-only users. + * Searches verified_addresses first, then primary_address, then agent columns. */ export async function getUserFromDB( address: string, @@ -65,5 +159,73 @@ export async function getUserFromDB( .eq("primary_address", normalized) .single(); + if (byPrimary) return byPrimary; + + // Also check agent_wallet and agent_owner for externally registered agents + const { data: byAgentWallet } = await supabase + .from("users") + .select("*") + .eq("agent_wallet", normalized) + .single(); + + if (byAgentWallet) return byAgentWallet; + + const { data: byAgentOwner } = await supabase + .from("users") + .select("*") + .eq("agent_owner", normalized) + .single(); + + return byAgentOwner ?? null; +} + +/** + * Look up an agent user from the DB, prioritizing rows with agent_id. + * Use this for agent-specific lookups (detection, management, metadata). + */ +export async function getAgentUserFromDB( + address: string, +): Promise { + const supabase = createServiceRoleClient(); + if (!supabase) return null; + + const normalized = address.toLowerCase(); + + // First: find a row with agent_id keyed by agent_wallet or agent_owner + const { data: byAgentWallet } = await supabase + .from("users") + .select("*") + .eq("agent_wallet", normalized) + .not("agent_id", "is", null) + .single(); + + if (byAgentWallet) return byAgentWallet; + + const { data: byAgentOwner } = await supabase + .from("users") + .select("*") + .eq("agent_owner", normalized) + .not("agent_id", "is", null) + .single(); + + if (byAgentOwner) return byAgentOwner; + + // Fallback: check standard address columns for rows with agent_id + const { data: byVerified } = await supabase + .from("users") + .select("*") + .contains("verified_addresses", [normalized]) + .not("agent_id", "is", null) + .single(); + + if (byVerified) return byVerified; + + const { data: byPrimary } = await supabase + .from("users") + .select("*") + .eq("primary_address", normalized) + .not("agent_id", "is", null) + .single(); + return byPrimary ?? null; } diff --git a/lib/contracts/erc8004.ts b/lib/contracts/erc8004.ts index aa7a6fe4..798a67d0 100644 --- a/lib/contracts/erc8004.ts +++ b/lib/contracts/erc8004.ts @@ -1,6 +1,7 @@ import { type Address } from "viem"; import { publicClient } from "../rpc"; import { ERC8004_REGISTRY } from "./constants"; +import { createServiceRoleClient } from "../supabase"; // --------------------------------------------------------------------------- // ABI @@ -13,6 +14,7 @@ import { ERC8004_REGISTRY } from "./constants"; export interface AgentMetadata { agentId?: string; owner?: string; + agentWallet?: string; name: string; description: string; genre?: string; @@ -182,6 +184,21 @@ export async function detectWriterType( writerAddress: Address ): Promise { try { + // DB-first: check if this address is a cached agent wallet + 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}`) + .not("agent_id", "is", null) + .limit(1) + .single(); + if (data?.agent_id) return 1; + } + + // RPC fallback for agents not yet in DB const agentId = await publicClient.readContract({ address: ERC8004_REGISTRY, abi: erc8004Abi, @@ -190,7 +207,7 @@ export async function detectWriterType( }); return agentId > BigInt(0) ? 1 : 0; } catch { - // Best-effort: default to human if registry query fails + // Best-effort: default to human if both checks fail return 0; } } @@ -265,3 +282,50 @@ export async function getAgentMetadata( return null; } } + +/** + * Resolve ERC-8004 agent metadata by agentId (not by wallet address). + * Use when you already know the agentId (e.g. from balanceOf/tokenOfOwnerByIndex). + */ +export async function getAgentMetadataById( + agentId: bigint, +): Promise { + try { + const [uri, owner, agentWallet] = await Promise.all([ + publicClient.readContract({ + address: ERC8004_REGISTRY, + abi: erc8004Abi, + functionName: "agentURI", + args: [agentId], + }), + publicClient.readContract({ + address: ERC8004_REGISTRY, + abi: erc8004Abi, + functionName: "ownerOf", + args: [agentId], + }).catch(() => undefined), + publicClient.readContract({ + address: ERC8004_REGISTRY, + abi: erc8004Abi, + functionName: "getAgentWallet", + args: [agentId], + }).catch(() => undefined), + ]); + if (!uri) return null; + + const parsed = await resolveAgentURI(uri as string); + return { + agentId: agentId.toString(), + owner: owner as string | undefined, + agentWallet: agentWallet as string | undefined, + 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, + registeredBy: (parsed.registeredBy as string) || undefined, + registeredAt: (parsed.registeredAt as string) || undefined, + }; + } catch { + return null; + } +} diff --git a/lib/supabase.ts b/lib/supabase.ts index 382015d4..c9e5d0d2 100644 --- a/lib/supabase.ts +++ b/lib/supabase.ts @@ -422,6 +422,14 @@ export interface Database { quotient_rank: number | null; quotient_labels: Json | null; quotient_updated_at: string | null; + agent_id: number | null; + agent_name: string | null; + agent_description: string | null; + agent_genre: string | null; + agent_llm_model: string | null; + agent_wallet: string | null; + agent_owner: string | null; + agent_registered_at: string | null; stats_fetched_at: string | null; steemhunt_fetched_at: string | null; created_at: string; @@ -457,6 +465,14 @@ export interface Database { quotient_rank?: number | null; quotient_labels?: Json | null; quotient_updated_at?: string | null; + agent_id?: number | null; + agent_name?: string | null; + agent_description?: string | null; + agent_genre?: string | null; + agent_llm_model?: string | null; + agent_wallet?: string | null; + agent_owner?: string | null; + agent_registered_at?: string | null; stats_fetched_at?: string | null; steemhunt_fetched_at?: string | null; created_at?: string; @@ -492,6 +508,14 @@ export interface Database { quotient_rank?: number | null; quotient_labels?: Json | null; quotient_updated_at?: string | null; + agent_id?: number | null; + agent_name?: string | null; + agent_description?: string | null; + agent_genre?: string | null; + agent_llm_model?: string | null; + agent_wallet?: string | null; + agent_owner?: string | null; + agent_registered_at?: string | null; stats_fetched_at?: string | null; steemhunt_fetched_at?: string | null; created_at?: string; diff --git a/src/app/agents/page.tsx b/src/app/agents/page.tsx index 2b6e3b6e..6554a594 100644 --- a/src/app/agents/page.tsx +++ b/src/app/agents/page.tsx @@ -1,8 +1,9 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect, useRef } from "react"; import { useAccount, useReadContract } from "wagmi"; -import { zeroAddress } from "viem"; +import { useQuery } from "@tanstack/react-query"; +import { cacheAgentById } from "../../../lib/actions"; import { ConnectWallet } from "../../components/ConnectWallet"; import { AgentRegister } from "../../components/AgentRegister"; import { AgentManage } from "../../components/AgentManage"; @@ -10,6 +11,8 @@ import { AgentBuild } from "../../components/AgentBuild"; import { AgentDashboard } from "../../components/AgentDashboard"; import { erc8004Abi } from "../../../lib/contracts/erc8004"; import { ERC8004_REGISTRY } from "../../../lib/contracts/constants"; +import type { User } from "../../../lib/supabase"; +import { getAgentUserFromDB } from "../../../lib/actions"; type Tab = "register" | "build" | "dashboard"; @@ -17,52 +20,76 @@ export default function AgentsPage() { const { isConnected, address } = useAccount(); const [tab, setTab] = useState("register"); - // Check if wallet is bound as an agent wallet - const { data: agentIdByWallet, isLoading: walletLoading } = useReadContract({ + // DB-first: check if user has cached agent data + const { data: dbUser, isLoading: dbLoading } = useQuery({ + queryKey: ["db-user-agent", address], + queryFn: () => getAgentUserFromDB(address!), + enabled: !!address, + }); + + const dbAgentId = dbUser?.agent_id; + const dbIsOwner = dbAgentId != null && dbUser?.agent_owner?.toLowerCase() === address?.toLowerCase(); + const dbIsAgentWallet = dbAgentId != null && dbUser?.agent_wallet?.toLowerCase() === address?.toLowerCase(); + const dbDetected = dbAgentId != null; + + // RPC fallback: only if DB has no agent data + const needsRpcFallback = !dbLoading && !dbDetected && !!address; + + const { data: rpcAgentId, isLoading: rpcWalletLoading } = useReadContract({ address: ERC8004_REGISTRY, abi: erc8004Abi, functionName: "agentIdByWallet", args: address ? [address] : undefined, - query: { enabled: !!address }, + query: { enabled: needsRpcFallback }, }); - // Check if wallet owns any agent NFTs (ERC-721 balanceOf) - const { data: nftBalance, isLoading: balanceLoading } = useReadContract({ + const { data: rpcBalance, isLoading: rpcBalanceLoading } = useReadContract({ address: ERC8004_REGISTRY, abi: erc8004Abi, functionName: "balanceOf", args: address ? [address] : undefined, - query: { enabled: !!address }, + query: { enabled: needsRpcFallback }, }); - // If owner, get the first owned token ID - const hasNft = nftBalance !== undefined && nftBalance > BigInt(0); - const { data: ownedTokenId, isLoading: tokenLoading } = useReadContract({ + 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: !!address && hasNft }, + query: { enabled: needsRpcFallback && rpcHasNft }, }); - const isAgentWallet = agentIdByWallet !== undefined && agentIdByWallet > BigInt(0); - const isOwner = hasNft && ownedTokenId !== undefined; - const detectLoading = walletLoading || balanceLoading || (hasNft && tokenLoading); + const rpcIsAgentWallet = rpcAgentId !== undefined && rpcAgentId > BigInt(0); + const rpcIsOwner = rpcHasNft && rpcOwnedToken !== undefined; - // Determine which agentId and role to use + // Combine DB + RPC results let detectedAgentId: bigint | undefined; let detectedRole: "owner" | "agentWallet" | undefined; - if (isOwner) { - detectedAgentId = ownedTokenId; + + if (dbDetected) { + detectedAgentId = BigInt(dbAgentId!); + detectedRole = dbIsOwner ? "owner" : dbIsAgentWallet ? "agentWallet" : "owner"; + } else if (rpcIsOwner) { + detectedAgentId = rpcOwnedToken; detectedRole = "owner"; - } else if (isAgentWallet) { - detectedAgentId = agentIdByWallet; + } else if (rpcIsAgentWallet) { + detectedAgentId = rpcAgentId; detectedRole = "agentWallet"; } const hasExistingAgent = detectedAgentId !== undefined && detectedRole !== undefined; + const detectLoading = dbLoading || (needsRpcFallback && (rpcWalletLoading || rpcBalanceLoading || (rpcHasNft && rpcTokenLoading))); + + // Auto-cache: when RPC fallback detects an agent not in DB, persist it + const cachedRef = useRef(false); + useEffect(() => { + if (!dbDetected && hasExistingAgent && address && detectedAgentId && !cachedRef.current) { + cachedRef.current = true; + cacheAgentById(address, detectedAgentId.toString()).catch(() => {}); + } + }, [dbDetected, hasExistingAgent, address, detectedAgentId]); - // Determine the label for the first tab const firstTabLabel = hasExistingAgent ? "Manage" : "Register"; return ( diff --git a/src/app/api/user/agent-register/route.ts b/src/app/api/user/agent-register/route.ts new file mode 100644 index 00000000..000ac66c --- /dev/null +++ b/src/app/api/user/agent-register/route.ts @@ -0,0 +1,79 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createServiceRoleClient } from "../../../../../lib/supabase"; + +/** + * POST /api/user/agent-register + * Upserts agent columns on the user row after on-chain registration. + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { walletAddress, agentId, name, description, genre, llmModel, agentWallet, agentOwner } = body; + + if (!walletAddress || typeof walletAddress !== "string" || !agentId) { + return NextResponse.json({ error: "walletAddress and agentId are required" }, { status: 400 }); + } + + const supabase = createServiceRoleClient(); + if (!supabase) { + return NextResponse.json({ error: "Database not configured" }, { status: 500 }); + } + + const normalized = walletAddress.toLowerCase(); + + // Find existing user — prefer rows with agent_id, then standard address columns + const { data: byAgentWallet } = await supabase + .from("users") + .select("id") + .eq("agent_wallet", normalized) + .not("agent_id", "is", null) + .single(); + + const { data: byAgentOwner } = !byAgentWallet + ? await supabase.from("users").select("id").eq("agent_owner", normalized).not("agent_id", "is", null).single() + : { data: byAgentWallet }; + + // Fallback: standard address columns (any row, even without agent_id — we'll update it) + let existingUser = byAgentWallet ?? byAgentOwner; + if (!existingUser) { + const { data: byVerified } = await supabase + .from("users") + .select("id") + .contains("verified_addresses", [normalized]) + .single(); + + const { data: byPrimary } = !byVerified + ? await supabase.from("users").select("id").eq("primary_address", normalized).single() + : { data: byVerified }; + + existingUser = byVerified ?? byPrimary; + } + + const agentFields = { + agent_id: Number(agentId), + agent_name: name || null, + agent_description: description || null, + agent_genre: genre || null, + agent_llm_model: llmModel || null, + agent_wallet: agentWallet?.toLowerCase() || null, + agent_owner: (agentOwner || walletAddress).toLowerCase(), + agent_registered_at: new Date().toISOString(), + }; + + if (existingUser) { + await supabase.from("users").update(agentFields).eq("id", existingUser.id); + } else { + await supabase.from("users").insert({ + primary_address: normalized, + ...agentFields, + }); + } + + return NextResponse.json({ ok: true }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Internal error" }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/user/agent-update/route.ts b/src/app/api/user/agent-update/route.ts new file mode 100644 index 00000000..32f76f76 --- /dev/null +++ b/src/app/api/user/agent-update/route.ts @@ -0,0 +1,89 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createServiceRoleClient } from "../../../../../lib/supabase"; + +/** + * POST /api/user/agent-update + * Updates specific agent columns on the user row after management actions. + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { walletAddress, fields } = body; + + if (!walletAddress || typeof walletAddress !== "string" || !fields || typeof fields !== "object") { + return NextResponse.json({ error: "walletAddress and fields are required" }, { status: 400 }); + } + + const supabase = createServiceRoleClient(); + if (!supabase) { + return NextResponse.json({ error: "Database not configured" }, { status: 500 }); + } + + const normalized = walletAddress.toLowerCase(); + + // Allow only known agent columns + const allowedKeys = [ + "agent_name", "agent_description", "agent_genre", + "agent_llm_model", "agent_wallet", "agent_owner", + ]; + const sanitized: Record = {}; + for (const key of allowedKeys) { + if (key in fields) { + sanitized[key] = fields[key] != null ? String(fields[key]).toLowerCase() : null; + } + } + // Name/description/genre/model should preserve case + for (const key of ["agent_name", "agent_description", "agent_genre", "agent_llm_model"]) { + if (key in fields) { + sanitized[key] = fields[key] || null; + } + } + + if (Object.keys(sanitized).length === 0) { + return NextResponse.json({ error: "No valid fields to update" }, { status: 400 }); + } + + // Find user row with agent_id, prioritizing agent-specific columns + const { data: byAgentWallet } = await supabase + .from("users") + .select("id") + .eq("agent_wallet", normalized) + .not("agent_id", "is", null) + .single(); + + const { data: byAgentOwner } = !byAgentWallet + ? await supabase.from("users").select("id").eq("agent_owner", normalized).not("agent_id", "is", null).single() + : { data: byAgentWallet }; + + const { data: byVerified } = !(byAgentWallet ?? byAgentOwner) + ? await supabase.from("users").select("id").contains("verified_addresses", [normalized]).not("agent_id", "is", null).single() + : { data: null }; + + const { data: byPrimary } = !(byAgentWallet ?? byAgentOwner ?? byVerified) + ? await supabase.from("users").select("id").eq("primary_address", normalized).not("agent_id", "is", null).single() + : { data: null }; + + // Fallback: any matching row (even without agent_id) + let existingUser = byAgentWallet ?? byAgentOwner ?? byVerified ?? byPrimary; + if (!existingUser) { + const { data: anyMatch } = await supabase + .from("users") + .select("id") + .or(`primary_address.eq.${normalized},agent_wallet.eq.${normalized},agent_owner.eq.${normalized}`) + .single(); + existingUser = anyMatch; + } + if (!existingUser) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + await supabase.from("users").update(sanitized).eq("id", existingUser.id); + + return NextResponse.json({ ok: true }); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Internal error" }, + { status: 500 }, + ); + } +} diff --git a/src/components/AgentDashboard.tsx b/src/components/AgentDashboard.tsx index 8f6586f3..ae1cd0f1 100644 --- a/src/components/AgentDashboard.tsx +++ b/src/components/AgentDashboard.tsx @@ -1,63 +1,103 @@ "use client"; +import { useEffect, useRef } from "react"; import { useAccount, useReadContract } 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, cacheAgentById } from "../../lib/actions"; export function AgentDashboard() { const { address } = useAccount(); - // Check if wallet is registered as an agent wallet - const { data: agentIdByWallet, isLoading: walletLoading } = useReadContract({ + // DB-first: check cached agent data + const { data: dbUser, isLoading: dbLoading } = useQuery({ + queryKey: ["db-user-dashboard", address], + queryFn: () => getAgentUserFromDB(address!), + enabled: !!address, + }); + + 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; + + // RPC fallback: only if DB has no agent data + const needsRpcFallback = !dbLoading && !dbDetected && !!address; + + const { data: rpcAgentId, isLoading: rpcWalletLoading } = useReadContract({ address: ERC8004_REGISTRY, abi: erc8004Abi, functionName: "agentIdByWallet", args: address ? [address] : undefined, - query: { enabled: !!address }, + query: { enabled: needsRpcFallback }, }); - // Check if wallet owns agent NFTs (owner role) - const { data: nftBalance, isLoading: balanceLoading } = useReadContract({ + const { data: rpcBalance, isLoading: rpcBalanceLoading } = useReadContract({ address: ERC8004_REGISTRY, abi: erc8004Abi, functionName: "balanceOf", args: address ? [address] : undefined, - query: { enabled: !!address }, + query: { enabled: needsRpcFallback }, }); - const hasNft = nftBalance !== undefined && nftBalance > BigInt(0); - const { data: ownedTokenId, isLoading: tokenLoading } = useReadContract({ + 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: !!address && hasNft }, + query: { enabled: needsRpcFallback && rpcHasNft }, }); - // Get the agent wallet for the owned token (to query storylines by that address) - const isOwner = hasNft && ownedTokenId !== undefined; - const { data: boundAgentWallet } = useReadContract({ + const rpcIsOwner = rpcHasNft && rpcOwnedToken !== undefined; + const { data: rpcBoundWallet } = useReadContract({ address: ERC8004_REGISTRY, abi: erc8004Abi, functionName: "getAgentWallet", - args: ownedTokenId !== undefined ? [ownedTokenId] : undefined, - query: { enabled: isOwner }, + args: rpcOwnedToken !== undefined ? [rpcOwnedToken] : undefined, + query: { enabled: needsRpcFallback && rpcIsOwner }, }); - const isAgentWallet = agentIdByWallet !== undefined && agentIdByWallet > BigInt(0); - const agentId = isOwner ? ownedTokenId : isAgentWallet ? agentIdByWallet : undefined; + 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 isAgent = agentId !== undefined; + const detectLoading = dbLoading || (needsRpcFallback && (rpcWalletLoading || rpcBalanceLoading || (rpcHasNft && rpcTokenLoading))); - // Determine the writer address for storyline lookup - // If connected as owner, prefer the bound agent wallet but fall back to owner address - // when the agent wallet is unset (zero address) or missing - const hasValidAgentWallet = - boundAgentWallet && boundAgentWallet !== "0x0000000000000000000000000000000000000000"; - const writerAddress = isOwner - ? hasValidAgentWallet ? (boundAgentWallet as string) : address - : 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(() => {}); + } + }, [dbDetected, isAgent, address, agentId]); // Fetch agent's storylines from Supabase const { data: storylines, isLoading: storylinesLoading } = useQuery({ @@ -71,7 +111,7 @@ export function AgentDashboard() { enabled: !!writerAddress && isAgent, }); - if (walletLoading || balanceLoading || (hasNft && tokenLoading)) { + if (detectLoading) { return (

Loading agent status...

diff --git a/src/components/AgentManage.tsx b/src/components/AgentManage.tsx index 8d8b4894..cdb1cec6 100644 --- a/src/components/AgentManage.tsx +++ b/src/components/AgentManage.tsx @@ -155,6 +155,20 @@ export function AgentManage({ agentId, role }: AgentManageProps) { await publicClient.waitForTransactionReceipt({ hash }); const parsed = JSON.parse(editUri); setMetadata({ ...metadata!, ...parsed }); + // Persist URI update to DB + fetch("/api/user/agent-update", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + walletAddress: address, + fields: { + agent_name: parsed.name, + agent_description: parsed.description, + agent_genre: parsed.genre || null, + agent_llm_model: parsed.llmModel || null, + }, + }), + }).catch(() => {}); setEditing(false); } catch (err) { setError(err instanceof Error ? err.message : "Failed to update URI"); @@ -175,6 +189,15 @@ export function AgentManage({ agentId, role }: AgentManageProps) { }); setTxHash(hash); await publicClient.waitForTransactionReceipt({ hash }); + // Persist unset to DB + fetch("/api/user/agent-update", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + walletAddress: address, + fields: { agent_wallet: null }, + }), + }).catch(() => {}); } catch (err) { setError(err instanceof Error ? err.message : "Failed to unset wallet"); } finally { @@ -222,6 +245,15 @@ export function AgentManage({ agentId, role }: AgentManageProps) { }); setTxHash(hash); await publicClient.waitForTransactionReceipt({ hash }); + // Persist new wallet binding to DB + fetch("/api/user/agent-update", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + walletAddress: address, + fields: { agent_wallet: newWalletAddr.toLowerCase() }, + }), + }).catch(() => {}); setWalletStep(null); setChangingWallet(false); setNewWalletAddr(""); diff --git a/src/components/AgentRegister.tsx b/src/components/AgentRegister.tsx index 770c0167..ba1101d3 100644 --- a/src/components/AgentRegister.tsx +++ b/src/components/AgentRegister.tsx @@ -91,11 +91,30 @@ export function AgentRegister() { return decoded.eventName === "Registered"; } catch { return false; } }); + let newAgentId: bigint | undefined; if (registeredLog) { const decoded = decodeEventLog({ abi: erc8004Abi, data: registeredLog.data, topics: registeredLog.topics }); - if (decoded.eventName === "Registered") setAgentId(decoded.args.agentId); + if (decoded.eventName === "Registered") { + newAgentId = decoded.args.agentId; + setAgentId(newAgentId); + } } setOwnerAddress(address); + // Persist agent data to DB + const meta = JSON.parse(agentURI); + fetch("/api/user/agent-register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + walletAddress: address, + agentId: newAgentId?.toString(), + name: meta.name, + description: meta.description, + genre: meta.genre, + llmModel: meta.llmModel, + agentOwner: address, + }), + }).catch(() => {}); setStep("3a"); } catch (err) { setError(err instanceof Error ? err.message : "Registration failed"); @@ -139,6 +158,15 @@ export function AgentRegister() { }); 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: ownerAddress, + fields: { agent_wallet: agentWallet.toLowerCase() }, + }), + }).catch(() => {}); setStep("done"); } catch (err) { setError(err instanceof Error ? err.message : "Wallet binding failed"); diff --git a/supabase/migrations/00029_users_agent_columns.sql b/supabase/migrations/00029_users_agent_columns.sql new file mode 100644 index 00000000..7c1de5de --- /dev/null +++ b/supabase/migrations/00029_users_agent_columns.sql @@ -0,0 +1,12 @@ +-- Add ERC-8004 agent identity columns to users table +ALTER TABLE users ADD COLUMN IF NOT EXISTS agent_id INTEGER UNIQUE; +ALTER TABLE users ADD COLUMN IF NOT EXISTS agent_name TEXT; +ALTER TABLE users ADD COLUMN IF NOT EXISTS agent_description TEXT; +ALTER TABLE users ADD COLUMN IF NOT EXISTS agent_genre TEXT; +ALTER TABLE users ADD COLUMN IF NOT EXISTS agent_llm_model TEXT; +ALTER TABLE users ADD COLUMN IF NOT EXISTS agent_wallet TEXT; +ALTER TABLE users ADD COLUMN IF NOT EXISTS agent_owner TEXT; +ALTER TABLE users ADD COLUMN IF NOT EXISTS agent_registered_at TIMESTAMPTZ; + +CREATE INDEX IF NOT EXISTS idx_users_agent_id ON users (agent_id) WHERE agent_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_users_agent_wallet ON users (agent_wallet) WHERE agent_wallet IS NOT NULL;