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
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": "0.1.16",
"version": "0.1.17",
"private": true,
"workspaces": [
"packages/*"
Expand Down
11 changes: 6 additions & 5 deletions src/app/agents/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { useAccount, useReadContract } from "wagmi";
import { useQuery } from "@tanstack/react-query";
import { ConnectWallet } from "../../components/ConnectWallet";
import { AgentRegister } from "../../components/AgentRegister";
import { AgentManage } from "../../components/AgentManage";
import { AgentManageAll } from "../../components/AgentManage";
import { AgentBuild } from "../../components/AgentBuild";
import { AgentDashboard } from "../../components/AgentDashboard";
import { erc8004Abi } from "../../../lib/contracts/erc8004";
Expand Down Expand Up @@ -63,12 +63,13 @@ function AgentsPageInner() {
query: { enabled: needsRpcFallback },
});

// Always check balanceOf to detect owned agent NFTs (even for known users without agent_id)
const { data: rpcBalance, isLoading: rpcBalanceLoading } = useReadContract({
address: ERC8004_REGISTRY,
abi: erc8004Abi,
functionName: "balanceOf",
args: address ? [address] : undefined,
query: { enabled: needsRpcFallback },
query: { enabled: !!address && !dbDetected },
});

const rpcHasNft = rpcBalance !== undefined && rpcBalance > BigInt(0);
Expand Down Expand Up @@ -98,8 +99,8 @@ function AgentsPageInner() {
detectedRole = "agentWallet";
}

const hasExistingAgent = detectedAgentId !== undefined && detectedRole !== undefined;
const detectLoading = dbLoading || (!dbDetected && userExistsLoading) || (needsRpcFallback && (rpcWalletLoading || rpcBalanceLoading || (rpcHasNft && rpcTokenLoading)));
const hasExistingAgent = (detectedAgentId !== undefined && detectedRole !== undefined) || rpcHasNft;
const detectLoading = dbLoading || (!dbDetected && rpcBalanceLoading) || (needsRpcFallback && (rpcWalletLoading || (rpcHasNft && rpcTokenLoading)));

// Auto-cache: when RPC fallback detects an agent not in DB, persist it
const cachedRef = useRef(false);
Expand Down Expand Up @@ -194,7 +195,7 @@ npx plotlink-ows # start writing`}
<p className="text-muted text-sm">Detecting agent status...</p>
</div>
) : hasExistingAgent ? (
<AgentManage agentId={detectedAgentId!} role={detectedRole!} />
<AgentManageAll />
) : (
<AgentRegister />
)
Expand Down
197 changes: 192 additions & 5 deletions src/components/AgentManage.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { useState, useEffect, useMemo } from "react";
import { useAccount, useWriteContract, useReadContract, useSignTypedData } from "wagmi";
import { useAccount, useWriteContract, useReadContract, useReadContracts, useSignTypedData } from "wagmi";
import { type Hex, type Address, zeroAddress } from "viem";
import { browserClient as publicClient } from "../../lib/rpc";
import { erc8004Abi, resolveAgentURI, type AgentMetadata } from "../../lib/contracts/erc8004";
Expand Down Expand Up @@ -37,9 +37,10 @@ const SET_WALLET_TYPES = {
interface AgentManageProps {
agentId: bigint;
role: "owner" | "agentWallet";
source?: "ows" | "direct";
}

export function AgentManage({ agentId, role }: AgentManageProps) {
export function AgentManage({ agentId, role, source }: AgentManageProps) {
const { address } = useAccount();
const { writeContractAsync } = useWriteContract();
const { signTypedDataAsync } = useSignTypedData();
Expand Down Expand Up @@ -67,6 +68,10 @@ export function AgentManage({ agentId, role }: AgentManageProps) {
const [signingWallet, setSigningWallet] = useState(false);
const [submittingWallet, setSubmittingWallet] = useState(false);

// OWS wallet change (paste-based flow)
const [owsPastedSig, setOwsPastedSig] = useState("");
const [owsPastedDeadline, setOwsPastedDeadline] = useState("");

// Unset wallet state
const [unsettingWallet, setUnsettingWallet] = useState(false);

Expand Down Expand Up @@ -316,9 +321,18 @@ export function AgentManage({ agentId, role }: AgentManageProps) {
<div className="border-accent/30 bg-accent/5 rounded border px-4 py-3">
<div className="flex items-center justify-between">
<div>
<p className="text-accent text-sm font-medium">
{metadata?.name ?? "Agent"} #{agentId.toString()}
</p>
<div className="flex items-center gap-2">
<p className="text-accent text-sm font-medium">
{metadata?.name ?? "Agent"} #{agentId.toString()}
</p>
{source && (
<span className={`rounded border px-1.5 py-0.5 text-[9px] font-medium ${
source === "ows" ? "border-accent/30 text-accent" : "border-border text-muted"
}`}>
{source === "ows" ? "OWS Writer" : "Direct"}
</span>
)}
</div>
<p className="text-muted mt-0.5 text-xs">
{role === "owner" ? "You own this agent" : "Your wallet is bound to this agent"}
</p>
Expand Down Expand Up @@ -460,7 +474,64 @@ export function AgentManage({ agentId, role }: AgentManageProps) {
</button>
)}
</div>
) : source === "ows" ? (
/* OWS agents: paste-based flow (signature generated on local OWS app) */
<div className="border-border rounded border p-4 space-y-4">
<div>
<label className="text-foreground mb-2 block text-sm">New OWS Wallet Address</label>
<input type="text" value={newWalletAddr} onChange={(e) => setNewWalletAddr(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" />
</div>
<p className="text-muted text-xs leading-relaxed">
Go to your OWS app &rarr; Settings &rarr; enter Agent ID <code className="text-accent">{agentId.toString()}</code> &rarr; click &quot;Generate Wallet Bind Code&quot;. Paste the signature and deadline below.
</p>
<div>
<label className="text-foreground mb-1 block text-xs">Wallet Bind Signature</label>
<input type="text" value={owsPastedSig} onChange={(e) => setOwsPastedSig(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" />
</div>
<div>
<label className="text-foreground mb-1 block text-xs">Deadline (unix timestamp)</label>
<input type="text" value={owsPastedDeadline} onChange={(e) => setOwsPastedDeadline(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" />
</div>
<div className="flex gap-3">
<button onClick={() => { setChangingWallet(false); setNewWalletAddr(""); setOwsPastedSig(""); setOwsPastedDeadline(""); }}
className="border-border text-muted hover:text-foreground rounded border px-4 py-2 text-xs transition-colors">Cancel</button>
<button
onClick={async () => {
if (!newWalletAddr || !owsPastedSig || !owsPastedDeadline) return;
try {
setError(null);
setSubmittingWallet(true);
const hash = await writeContractAsync({
address: ERC8004_REGISTRY,
abi: erc8004Abi,
functionName: "setAgentWallet",
args: [agentId, newWalletAddr as Address, BigInt(owsPastedDeadline), owsPastedSig as Hex],
});
setTxHash(hash);
await publicClient.waitForTransactionReceipt({ hash });
fetch("/api/user/agent-update", { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ walletAddress: address, fields: { agent_wallet: newWalletAddr.toLowerCase() } }),
}).catch(() => {});
setSuccessMessage("Agent wallet updated");
setChangingWallet(false); setNewWalletAddr(""); setOwsPastedSig(""); setOwsPastedDeadline("");
} catch (err) {
setError(err instanceof Error ? err.message : "Wallet binding failed");
} finally {
setSubmittingWallet(false);
}
}}
disabled={submittingWallet || !newWalletAddr.match(/^0x[a-fA-F0-9]{40}$/) || !owsPastedSig.startsWith("0x") || !owsPastedDeadline}
className="border-accent text-accent hover:bg-accent hover:text-background flex-1 rounded border py-2 text-xs font-medium transition-colors disabled:opacity-50"
>
{submittingWallet ? "Binding..." : "Submit Wallet Binding"}
</button>
</div>
</div>
) : (
/* Direct agents: browser-based sign flow (existing) */
<div className="border-border rounded border p-4 space-y-4">
{walletStep === "enter" && (
<>
Expand Down Expand Up @@ -574,3 +645,119 @@ export function AgentManage({ agentId, role }: AgentManageProps) {
</div>
);
}

const ZERO_ADDR = "0x0000000000000000000000000000000000000000";

/** Wrapper that enumerates all agents owned by the connected wallet */
export function AgentManageAll() {
const { address } = useAccount();

const { data: balance, isLoading: balanceLoading } = useReadContract({
address: ERC8004_REGISTRY,
abi: erc8004Abi,
functionName: "balanceOf",
args: address ? [address] : undefined,
query: { enabled: !!address },
});

const agentCount = balance !== undefined ? Number(balance) : 0;

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 agentIds = useMemo(() => {
if (!tokenResults) return [];
return tokenResults
.filter((r) => r.status === "success" && r.result !== undefined)
.map((r) => r.result as bigint);
}, [tokenResults]);

// Fetch agentURI + getAgentWallet for each agent to determine source
const metaCalls = 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: metaResults, isLoading: metaLoading } = useReadContracts({
contracts: metaCalls,
query: { enabled: metaCalls.length > 0 },
});

const agents = useMemo(() => {
if (agentIds.length === 0 || !metaResults) return [];
return agentIds.map((id, i) => {
const uriResult = metaResults[i * 2];
const walletResult = metaResults[i * 2 + 1];
let source: "ows" | "direct" = "direct";
if (uriResult?.status === "success" && uriResult.result) {
try {
const meta = JSON.parse(uriResult.result as string);
if (meta.type === "ows-writer" || meta.owsWallet) source = "ows";
} catch { /* not JSON */ }
}
const walletAddr = walletResult?.status === "success" ? (walletResult.result as string) : undefined;
if (walletAddr && walletAddr !== ZERO_ADDR) source = "ows";
return { agentId: id, source };
});
}, [agentIds, metaResults]);

// Also check if connected wallet is an agent wallet
const { data: selfAgentId } = useReadContract({
address: ERC8004_REGISTRY,
abi: erc8004Abi,
functionName: "agentIdByWallet",
args: address ? [address] : undefined,
query: { enabled: !!address },
});
const isSelfAgent = selfAgentId !== undefined && selfAgentId > BigInt(0);
const selfInList = agents.some((a) => a.agentId === selfAgentId);

const isLoading = balanceLoading || tokensLoading || metaLoading;

if (isLoading) {
return (
<div className="mt-6 py-8 text-center">
<p className="text-muted text-sm">Loading agents...</p>
</div>
);
}

const hasAgents = agents.length > 0 || (isSelfAgent && !selfInList);

if (!hasAgents) {
return (
<div className="mt-6 py-8 text-center">
<p className="text-muted text-sm mb-2">You have no AI agents registered.</p>
<p className="text-muted text-xs">
Switch to the <span className="text-accent font-medium">Register</span> tab to register an agent.
</p>
</div>
);
}

return (
<div className="mt-6 space-y-8">
{agents.map((agent) => (
<AgentManage key={agent.agentId.toString()} agentId={agent.agentId} role="owner" source={agent.source} />
))}
{isSelfAgent && !selfInList && (
<AgentManage agentId={selfAgentId} role="agentWallet" source="direct" />
)}
</div>
);
}
Loading