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
57 changes: 49 additions & 8 deletions lib/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand 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 };
}

/**
Expand Down Expand Up @@ -286,19 +295,51 @@ 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;

// If the queried address is an OWS-linked owner (not the agent itself),
// 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
Expand Down
5 changes: 3 additions & 2 deletions lib/contracts/erc8004.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,14 +184,15 @@ export async function detectWriterType(
writerAddress: Address
): Promise<number> {
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();
Expand Down
3 changes: 3 additions & 0 deletions lib/supabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
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.24",
"version": "0.1.25",
"private": true,
"workspaces": [
"packages/*"
Expand Down
128 changes: 128 additions & 0 deletions src/app/api/user/link-agent/route.ts
Original file line number Diff line number Diff line change
@@ -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 },
);
}
}
58 changes: 53 additions & 5 deletions src/app/profile/[address]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import { getFullUserProfile, getFarcasterProfile } from "../../../../lib/actions";
import { truncateAddress } from "../../../../lib/utils";
import { formatPrice, formatSupply } from "../../../../lib/format";
import { getTokenPrice, mcv2BondAbi, erc20Abi, type TokenPriceInfo, get24hPriceChange, getTokenTVL } from "../../../../lib/price";

Check warning on line 14 in src/app/profile/[address]/page.tsx

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

'TokenPriceInfo' is defined but never used
import { browserClient } from "../../../../lib/rpc";
import type { FarcasterProfile } from "../../../../lib/farcaster";
import type { AgentMetadata } from "../../../../lib/contracts/erc8004";
Expand Down Expand Up @@ -53,6 +53,7 @@
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)
Expand Down Expand Up @@ -135,7 +136,7 @@
if (r <= 0) clearInterval(interval);
}, 1000);
return () => clearInterval(interval);
}, [dbUser?.steemhunt_fetched_at]);

Check warning on line 139 in src/app/profile/[address]/page.tsx

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

React Hook useEffect has a missing dependency: 'COOLDOWN_MS'. Either include it or remove the dependency array

const onCooldown = cooldownRemaining > 0;

Expand All @@ -149,6 +150,7 @@
agentLoading={agentLoading}
isAgent={isAgent}
isAgentOwner={isAgentOwner}
linkedAgentMeta={linkedAgentMeta}
claimedRoyalties={claimedRoyalties ?? null}
plotBalance={plotBalance ?? null}
plotUsdPrice={plotUsdPrice ?? null}
Expand Down Expand Up @@ -213,6 +215,7 @@
agentLoading,
isAgent,
isAgentOwner,
linkedAgentMeta,
claimedRoyalties,
plotBalance,
plotUsdPrice,
Expand All @@ -231,6 +234,7 @@
agentLoading: boolean;
isAgent: boolean;
isAgentOwner: boolean;
linkedAgentMeta: AgentMetadata | null;
claimedRoyalties: bigint | null;
plotBalance: bigint | null;
plotUsdPrice: number | null;
Expand Down Expand Up @@ -411,13 +415,11 @@
</div>
)}

{/* Agent Identity card — shown for registered agents and agent owners */}
{(isAgent || isAgentOwner) && agentMeta && (
{/* Agent Identity card — shown for registered agents */}
{isAgent && agentMeta && (
<div className="border-border rounded border p-3">
<div className="flex items-center justify-between">
<span className="text-muted text-[10px] font-medium uppercase tracking-wider">
{isAgentOwner ? "Linked AI Writer" : "Agent Identity"}
</span>
<span className="text-muted text-[10px] font-medium uppercase tracking-wider">Agent Identity</span>
<span className="bg-accent/10 text-accent rounded px-1 py-0.5 text-[9px] font-medium">ERC-8004</span>
</div>
<div className="mt-1.5 space-y-1.5">
Expand Down Expand Up @@ -470,6 +472,52 @@
</div>
)}

{/* Linked AI Writer card — shown for human agent owners */}
{isAgentOwner && (
<div className="border-border rounded border p-3">
<div className="flex items-center justify-between">
<span className="text-muted text-[10px] font-medium uppercase tracking-wider">Linked AI Writer</span>
{linkedAgentMeta?.agentId && (
<span className="bg-accent/10 text-accent rounded px-1 py-0.5 text-[9px] font-medium">ERC-8004</span>
)}
</div>
<div className="mt-1.5 space-y-1.5">
{linkedAgentMeta ? (
<>
{linkedAgentMeta.agentId && (
<div className="text-xs">
<span className="text-muted">Agent ID: </span>
<span className="text-foreground font-mono font-medium">{linkedAgentMeta.agentId}</span>
</div>
)}
<div className="text-xs">
<span className="text-muted">Name: </span>
<span className="text-foreground font-medium">{linkedAgentMeta.name}</span>
</div>
{linkedAgentMeta.llmModel && (
<div className="text-xs">
<span className="text-muted">Model: </span>
<span className="text-foreground font-medium">{linkedAgentMeta.llmModel}</span>
</div>
)}
</>
) : (
<div className="text-xs">
<span className="text-muted">OWS wallet linked. Agent will appear here once registered on-chain.</span>
</div>
)}
{dbUser?.linked_agent_wallet && (
<div className="text-xs">
<span className="text-muted">Wallet: </span>
<Link href={`/profile/${dbUser.linked_agent_wallet}`} className="text-accent hover:underline font-mono">
{truncateAddress(dbUser.linked_agent_wallet)}
</Link>
</div>
)}
</div>
</div>
)}

{/* Wallet identity card — always shown */}
<div className="border-border rounded border p-3">
<span className="text-muted text-[10px] font-medium uppercase tracking-wider">Wallet</span>
Expand Down Expand Up @@ -695,7 +743,7 @@
});

// Claimable royalties (own profile only)
const { data: royaltyInfo } = useQuery({

Check warning on line 746 in src/app/profile/[address]/page.tsx

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

'royaltyInfo' is assigned a value but never used
queryKey: ["profile-royalties", address],
queryFn: async () => {
const [balance, claimed] = await browserClient.readContract({
Expand Down Expand Up @@ -852,7 +900,7 @@
}) {
const tokenAddr = storyline.token_address as Address;

const { data: priceInfo } = useQuery({

Check warning on line 903 in src/app/profile/[address]/page.tsx

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

'priceInfo' is assigned a value but never used
queryKey: ["profile-story-price", storyline.token_address],
queryFn: () => getTokenPrice(tokenAddr, browserClient),
enabled: !!storyline.token_address,
Expand Down
Loading
Loading