From e61d4f4fc74105a1cb97d22ebdb2539d925b3413 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sun, 5 Apr 2026 06:53:06 +0100 Subject: [PATCH 1/4] [#842] Split Register tab: Link AI Writer + Register New Agent Add "Link AI Writer (PlotLink OWS App)" section at top of Register tab with OWS wallet binding verification flow. User pastes OWS wallet address and binding signature, verified via ecrecover, then registers on ERC-8004 with human wallet as owner and OWS wallet bound via setAgentWallet. Add POST /api/user/verify-ows-binding endpoint that validates binding signatures using viem verifyMessage. Keep existing direct registration flow below as "Register New AI Agent" section for developers. Bump to v0.1.14. Fixes #842 Co-Authored-By: Claude Opus 4.6 (1M context) --- package.json | 2 +- src/app/api/user/verify-ows-binding/route.ts | 52 ++++ src/components/AgentRegister.tsx | 251 ++++++++++++++++++- 3 files changed, 302 insertions(+), 3 deletions(-) create mode 100644 src/app/api/user/verify-ows-binding/route.ts diff --git a/package.json b/package.json index 82a52aca..723da842 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "plotlink", - "version": "0.1.13", + "version": "0.1.14", "private": true, "workspaces": [ "packages/*" diff --git a/src/app/api/user/verify-ows-binding/route.ts b/src/app/api/user/verify-ows-binding/route.ts new file mode 100644 index 00000000..377a4053 --- /dev/null +++ b/src/app/api/user/verify-ows-binding/route.ts @@ -0,0 +1,52 @@ +import { NextRequest, NextResponse } from "next/server"; +import { verifyMessage } from "viem"; + +/** + * POST /api/user/verify-ows-binding + * Verifies that an OWS wallet binding signature is valid. + * + * Body: { owsWallet, humanWallet, signature } + * Message format: "I authorize {humanWallet} as my PlotLink owner. Wallet: {owsWallet}" + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { owsWallet, humanWallet, signature } = body; + + if (!owsWallet || !humanWallet || !signature) { + return NextResponse.json( + { valid: false, error: "owsWallet, humanWallet, and signature are required" }, + { status: 400 }, + ); + } + + if (!/^0x[a-fA-F0-9]{40}$/.test(owsWallet) || !/^0x[a-fA-F0-9]{40}$/.test(humanWallet)) { + return NextResponse.json( + { valid: false, error: "Invalid wallet address format" }, + { status: 400 }, + ); + } + + const message = `I authorize ${humanWallet} as my PlotLink owner. Wallet: ${owsWallet}`; + + const valid = await verifyMessage({ + address: owsWallet as `0x${string}`, + message, + signature: signature as `0x${string}`, + }); + + if (!valid) { + return NextResponse.json( + { valid: false, error: "Signature does not match the OWS wallet address" }, + { status: 400 }, + ); + } + + return NextResponse.json({ valid: true }); + } catch (err) { + return NextResponse.json( + { valid: false, error: err instanceof Error ? err.message : "Verification failed" }, + { status: 500 }, + ); + } +} diff --git a/src/components/AgentRegister.tsx b/src/components/AgentRegister.tsx index 835773c3..19b32c98 100644 --- a/src/components/AgentRegister.tsx +++ b/src/components/AgentRegister.tsx @@ -37,7 +37,223 @@ const SET_WALLET_TYPES = { ], } as const; -export function AgentRegister() { +/* ───────────────────────────────────────────────────────────────────────────── + * Link AI Writer — OWS binding verification + registration + * ───────────────────────────────────────────────────────────────────────────── */ + +function LinkAIWriter() { + const { address } = useAccount(); + const { writeContractAsync } = useWriteContract(); + const { signTypedDataAsync } = useSignTypedData(); + + 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 [bindingWallet, setBindingWallet] = useState(false); + const [bindTxHash, setBindTxHash] = useState(); + const [walletBound, setWalletBound] = useState(false); + const [error, setError] = useState(null); + + const validInputs = /^0x[a-fA-F0-9]{40}$/.test(owsWallet) && bindingSignature.startsWith("0x") && bindingSignature.length > 10; + + async function handleVerifyAndLink() { + 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 }); + + 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); + } + } + + // Persist to DB + try { + await fetch("/api/user/agent-register", { + 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", + agentWallet: owsWallet.toLowerCase(), + agentOwner: address, + }), + }); + } catch { /* best-effort */ } + + // Step 3: Bind OWS wallet via setAgentWallet (EIP-712 signature from owner) + if (newAgentId !== undefined) { + setLinking(false); + setBindingWallet(true); + const deadline = BigInt(Math.floor(Date.now() / 1000) + 300); + const signature = await signTypedDataAsync({ + domain: EIP712_DOMAIN, + types: SET_WALLET_TYPES, + primaryType: "AgentWalletSet", + message: { agentId: newAgentId, newWallet: owsWallet as `0x${string}`, owner: address, deadline }, + }); + + const bindHash = await writeContractAsync({ + address: ERC8004_REGISTRY, + abi: erc8004Abi, + functionName: "setAgentWallet", + args: [newAgentId, owsWallet as `0x${string}`, deadline, signature], + }); + setBindTxHash(bindHash); + await publicClient.waitForTransactionReceipt({ hash: bindHash }); + + // Persist wallet binding + 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 : "Linking failed"); + } finally { + setVerifying(false); + setLinking(false); + setBindingWallet(false); + } + } + + if (walletBound || (linkedAgentId !== undefined && !bindingWallet)) { + return ( +
+
+

+ Linked! Your AI writer is now registered. +

+ {linkedAgentId !== undefined &&

Agent ID: {linkedAgentId.toString()}

} + {walletBound &&

OWS wallet bound on-chain

} +
+ {linkTxHash && ( + + )} + {bindTxHash && ( + + )} +
+ ); + } + + return ( +
+

+ Connect your local PlotLink OWS Writer app to your PlotLink account. + Your AI writer will appear as "{address ? `${address.slice(0, 6)}...` : "your"}'s AI Writer" on PlotLink. +

+
+

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

+
+ +
+ + setOwsWallet(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" /> +
+
+ + setBindingSignature(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" /> +
+ + {error && ( +
{error}
+ )} + + {verified && !linking && !bindingWallet && ( +
+ Signature verified. Registering on-chain... +
+ )} + + {linkTxHash && !walletBound && ( + + )} + + +
+ ); +} + +/* ───────────────────────────────────────────────────────────────────────────── + * Direct Registration — existing flow (owner wallet = agent wallet) + * ───────────────────────────────────────────────────────────────────────────── */ + +function DirectRegister() { const { address } = useAccount(); const { writeContractAsync } = useWriteContract(); const { signTypedDataAsync } = useSignTypedData(); @@ -192,7 +408,7 @@ export function AgentRegister() { const stepNum = typeof step === "number" ? step : 3; return ( -
+
{/* Step indicator */}
{([1, 2] as const).map((s) => ( @@ -386,3 +602,34 @@ export function AgentRegister() {
); } + +/* ───────────────────────────────────────────────────────────────────────────── + * AgentRegister — combines both sections + * ───────────────────────────────────────────────────────────────────────────── */ + +export function AgentRegister() { + return ( +
+ {/* Section 1: Link AI Writer */} +
+

Link AI Writer (PlotLink OWS App)

+

No coding required — paste the binding code from your OWS app

+ +
+ + {/* Separator */} +
+
+ or +
+
+ + {/* Section 2: Direct Registration */} +
+

Register New AI Agent

+

For developers building custom agent integrations

+ +
+
+ ); +} From 47b7a8acf53246023fc4605feb6376bb23a54a23 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sun, 5 Apr 2026 06:59:07 +0100 Subject: [PATCH 2/4] [#842] Fix: remove setAgentWallet from Link AI Writer flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setAgentWallet requires the agent (OWS) wallet to sign EIP-712 data, but the OWS wallet is on the user's local machine and cannot sign via RainbowKit. Remove the bind step — register on-chain with human as owner, persist OWS wallet to DB for display. On-chain wallet binding can be done from the OWS app side in a future ticket. Also fix success state to only show after both on-chain registration and DB persist complete, preventing false success on partial failure. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/AgentRegister.tsx | 95 ++++++++++---------------------- 1 file changed, 29 insertions(+), 66 deletions(-) diff --git a/src/components/AgentRegister.tsx b/src/components/AgentRegister.tsx index 19b32c98..eac97d20 100644 --- a/src/components/AgentRegister.tsx +++ b/src/components/AgentRegister.tsx @@ -44,7 +44,6 @@ const SET_WALLET_TYPES = { function LinkAIWriter() { const { address } = useAccount(); const { writeContractAsync } = useWriteContract(); - const { signTypedDataAsync } = useSignTypedData(); const [owsWallet, setOwsWallet] = useState(""); const [bindingSignature, setBindingSignature] = useState(""); @@ -53,9 +52,7 @@ function LinkAIWriter() { const [linking, setLinking] = useState(false); const [linkTxHash, setLinkTxHash] = useState(); const [linkedAgentId, setLinkedAgentId] = useState(); - const [bindingWallet, setBindingWallet] = useState(false); - const [bindTxHash, setBindTxHash] = useState(); - const [walletBound, setWalletBound] = useState(false); + const [done, setDone] = useState(false); const [error, setError] = useState(null); const validInputs = /^0x[a-fA-F0-9]{40}$/.test(owsWallet) && bindingSignature.startsWith("0x") && bindingSignature.length > 10; @@ -114,65 +111,35 @@ function LinkAIWriter() { } } - // Persist to DB - try { - await fetch("/api/user/agent-register", { - 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", - agentWallet: owsWallet.toLowerCase(), - agentOwner: address, - }), - }); - } catch { /* best-effort */ } - - // Step 3: Bind OWS wallet via setAgentWallet (EIP-712 signature from owner) - if (newAgentId !== undefined) { - setLinking(false); - setBindingWallet(true); - const deadline = BigInt(Math.floor(Date.now() / 1000) + 300); - const signature = await signTypedDataAsync({ - domain: EIP712_DOMAIN, - types: SET_WALLET_TYPES, - primaryType: "AgentWalletSet", - message: { agentId: newAgentId, newWallet: owsWallet as `0x${string}`, owner: address, deadline }, - }); - - const bindHash = await writeContractAsync({ - address: ERC8004_REGISTRY, - abi: erc8004Abi, - functionName: "setAgentWallet", - args: [newAgentId, owsWallet as `0x${string}`, deadline, signature], - }); - setBindTxHash(bindHash); - await publicClient.waitForTransactionReceipt({ hash: bindHash }); - - // Persist wallet binding - 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); + // Step 3: Persist to DB (OWS wallet stored in DB for display; + // on-chain wallet binding via setAgentWallet requires the OWS wallet + // to sign EIP-712 data, which must be done from the OWS app side) + const cacheRes = await fetch("/api/user/agent-register", { + 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", + agentWallet: owsWallet.toLowerCase(), + agentOwner: address, + }), + }); + if (!cacheRes.ok) { + setError("On-chain registration succeeded, but DB cache failed — will sync on next visit"); } + + setDone(true); } catch (err) { setError(err instanceof Error ? err.message : "Linking failed"); } finally { setVerifying(false); setLinking(false); - setBindingWallet(false); } } - if (walletBound || (linkedAgentId !== undefined && !bindingWallet)) { + if (done) { return (
@@ -180,21 +147,17 @@ function LinkAIWriter() { Linked! Your AI writer is now registered.

{linkedAgentId !== undefined &&

Agent ID: {linkedAgentId.toString()}

} - {walletBound &&

OWS wallet bound on-chain

} +

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

{linkTxHash && ( )} - {bindTxHash && ( - + {error && ( +
{error}
)}
); @@ -227,13 +190,13 @@ function LinkAIWriter() {
{error}
)} - {verified && !linking && !bindingWallet && ( + {verified && !linking && (
Signature verified. Registering on-chain...
)} - {linkTxHash && !walletBound && ( + {linkTxHash && !done && ( )} -
); From e8d27976780d11503d21c7366a153ae8876ee835 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sun, 5 Apr 2026 07:04:43 +0100 Subject: [PATCH 3/4] [#842] Add on-chain wallet binding step to Link AI Writer flow After registration, show wallet bind step: user goes to OWS app, enters Agent ID, generates EIP-712 AgentWalletSet signature, then pastes signature + deadline on PlotLink to call setAgentWallet. This completes the on-chain owner/agent wallet split. The EIP-712 signature must come from the OWS (agent) wallet, not the browser wallet, so a round-trip to the OWS app is required. Companion endpoint added to plotlink-ows in a separate PR. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/AgentRegister.tsx | 74 +++++++++++++++++++++++++++++++- 1 file changed, 72 insertions(+), 2 deletions(-) diff --git a/src/components/AgentRegister.tsx b/src/components/AgentRegister.tsx index eac97d20..eff40b28 100644 --- a/src/components/AgentRegister.tsx +++ b/src/components/AgentRegister.tsx @@ -55,6 +55,13 @@ function LinkAIWriter() { 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() { @@ -139,23 +146,86 @@ function LinkAIWriter() { } } + 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 (

- Linked! Your AI writer is now registered. + {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)}

{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}
)} From a47545f7b21d9d0ff5fd539dee7c4c9ac0fc3494 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sun, 5 Apr 2026 07:10:48 +0100 Subject: [PATCH 4/4] [#842] Defer agent_wallet DB write until setAgentWallet succeeds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Don't write agent_wallet to DB during initial registration — only write it after the on-chain setAgentWallet tx confirms. Prevents misclassifying an unbound OWS wallet as a linked agent. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/AgentRegister.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/components/AgentRegister.tsx b/src/components/AgentRegister.tsx index eff40b28..5d1d55d2 100644 --- a/src/components/AgentRegister.tsx +++ b/src/components/AgentRegister.tsx @@ -118,9 +118,7 @@ function LinkAIWriter() { } } - // Step 3: Persist to DB (OWS wallet stored in DB for display; - // on-chain wallet binding via setAgentWallet requires the OWS wallet - // to sign EIP-712 data, which must be done from the OWS app side) + // Step 3: Persist to DB (agent_wallet deferred until setAgentWallet succeeds on-chain) const cacheRes = await fetch("/api/user/agent-register", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -129,7 +127,6 @@ function LinkAIWriter() { agentId: newAgentId?.toString(), name: "AI Writer", description: "AI fiction writer linked via PlotLink OWS", - agentWallet: owsWallet.toLowerCase(), agentOwner: address, }), });