diff --git a/src/features/incentive/PointSystem.ts b/src/features/incentive/PointSystem.ts index 47f9b49cf..7833b5944 100644 --- a/src/features/incentive/PointSystem.ts +++ b/src/features/incentive/PointSystem.ts @@ -8,7 +8,8 @@ import IdentityManager from "@/libs/blockchain/gcr/gcr_routines/identityManager" import ensureGCRForUser from "@/libs/blockchain/gcr/gcr_routines/ensureGCRForUser" import { Twitter } from "@/libs/identity/tools/twitter" import { UDIdentityManager } from "@/libs/blockchain/gcr/gcr_routines/udIdentityManager" -import { SavedUdIdentity } from "@/model/entities/types/IdentityTypes" +import { AgentIdentityManager } from "@/libs/blockchain/gcr/gcr_routines/agentIdentityManager" +import { SavedUdIdentity, SavedAgentIdentity } from "@/model/entities/types/IdentityTypes" import { UserPoints } from "@kynesyslabs/demosdk/abstraction" const pointValues = { @@ -20,12 +21,13 @@ const pointValues = { LINK_DISCORD: 1, LINK_UD_DOMAIN_DEMOS: 3, LINK_UD_DOMAIN: 1, + LINK_AGENT: 2, } export class PointSystem { private static instance: PointSystem - private constructor() {} + private constructor() { } public static getInstance(): PointSystem { if (!PointSystem.instance) { @@ -43,6 +45,14 @@ export class PointSystem { linkedUDDomains: { [network: string]: string[] } + linkedAgents: { + [chain: string]: Array<{ + agentId: string + evmAddress: string + tokenUri?: string + timestamp: number + }> + } }> { const xmIdentities = await IdentityManager.getIdentities(userId) const twitterIdentities = await IdentityManager.getWeb2Identities( @@ -61,6 +71,7 @@ export class PointSystem { ) const udIdentities = await IdentityManager.getUDIdentities(userId) + const agentIdentities = await AgentIdentityManager.getAgentIdentities(userId) const linkedWallets: string[] = [] const linkedUDDomains: { @@ -119,7 +130,31 @@ export class PointSystem { } } - return { linkedWallets, linkedSocials, linkedUDDomains } + const linkedAgents: { + [chain: string]: Array<{ + agentId: string + evmAddress: string + tokenUri?: string + timestamp: number + }> + } = {} + + if (Array.isArray(agentIdentities) && agentIdentities.length > 0) { + for (const agent of agentIdentities as SavedAgentIdentity[]) { + if (!linkedAgents[agent.chain]) { + linkedAgents[agent.chain] = [] + } + + linkedAgents[agent.chain].push({ + agentId: agent.agentId, + evmAddress: agent.evmAddress, + tokenUri: agent.tokenUri, + timestamp: agent.timestamp, + }) + } + } + + return { linkedWallets, linkedSocials, linkedUDDomains, linkedAgents } } /** @@ -135,7 +170,7 @@ export class PointSystem { const gcrMainRepository = db.getDataSource().getRepository(GCRMain) let account = await gcrMainRepository.findOneBy({ pubkey: userIdStr }) - const { linkedWallets, linkedSocials, linkedUDDomains } = + const { linkedWallets, linkedSocials, linkedUDDomains, linkedAgents } = await this.getUserIdentitiesFromGCR(userIdStr) if (!account) { @@ -171,12 +206,14 @@ export class PointSystem { account.points.breakdown?.socialAccounts?.discord ?? 0, }, udDomains: account.points.breakdown?.udDomains || {}, + agents: account.points.breakdown?.agents || {}, referrals: account.points.breakdown?.referrals || 0, demosFollow: account.points.breakdown?.demosFollow || 0, }, linkedWallets, linkedSocials, linkedUDDomains, + linkedAgents, lastUpdated: account.points.lastUpdated || new Date(), flagged: account.flagged || null, flaggedReason: account.flaggedReason || null, @@ -189,7 +226,7 @@ export class PointSystem { private async addPointsToGCR( userId: string, points: number, - type: "web3Wallets" | "socialAccounts" | "udDomains", + type: "web3Wallets" | "socialAccounts" | "udDomains" | "agents", platform: string, referralCode?: string, twitterUserId?: string, @@ -237,6 +274,14 @@ export class PointSystem { account.points.breakdown.udDomains[platform] || 0 account.points.breakdown.udDomains[platform] = oldDomainPoints + points + } else if (type === "agents") { + // Explicitly initialize agents if undefined + if (!account.points.breakdown.agents) { + account.points.breakdown.agents = {} + } + const oldAgentPoints = + account.points.breakdown.agents[platform] || 0 + account.points.breakdown.agents[platform] = oldAgentPoints + points } account.points.lastUpdated = new Date() @@ -376,8 +421,8 @@ export class PointSystem { message: walletIsAlreadyLinked ? walletIsAlreadyLinkedMessage : hasExistingWalletOnChain - ? hasExistingWalletOnChainMessage - : "Points awarded for linking wallet", + ? hasExistingWalletOnChainMessage + : "Points awarded for linking wallet", }, require_reply: false, extra: {}, @@ -1135,9 +1180,8 @@ export class PointSystem { response: { pointsAwarded: pointValue, totalPoints: updatedPoints.totalPoints, - message: `Points awarded for linking ${ - isDemosDomain ? ".demos" : "UD" - } domain`, + message: `Points awarded for linking ${isDemosDomain ? ".demos" : "UD" + } domain`, }, require_reply: false, extra: {}, @@ -1218,9 +1262,8 @@ export class PointSystem { response: { pointsDeducted: pointValue, totalPoints: updatedPoints.totalPoints, - message: `Points deducted for unlinking ${ - isDemosDomain ? ".demos" : "UD" - } domain`, + message: `Points deducted for unlinking ${isDemosDomain ? ".demos" : "UD" + } domain`, }, require_reply: false, extra: {}, @@ -1237,4 +1280,181 @@ export class PointSystem { } } } + + /** + * Award points for linking an ERC-8004 agent + * @param userId The user's Demos address + * @param agentId The ERC-8004 agent token ID + * @param chain The chain where agent is registered (e.g., "base.sepolia") + * @param referralCode Optional referral code + * @returns RPCResponse + */ + async awardAgentPoints( + userId: string, + agentId: string, + chain: string, + referralCode?: string, + ): Promise { + try { + // Validate and normalize chain to canonical form + const canonicalChain = AgentIdentityManager.validateChain(chain) + if (!canonicalChain) { + return { + result: 400, + response: { + pointsAwarded: 0, + totalPoints: 0, + message: `Invalid chain: ${chain}. Only base.sepolia is currently supported.`, + }, + require_reply: false, + extra: {}, + } + } + + // Create agentKey for point tracking: "chain:agentId" + const agentKey = `${canonicalChain}:${agentId}` + + // Verify the agent is actually linked to this user + const account = await ensureGCRForUser(userId) + const agentIdentities = account.identities.agent?.[canonicalChain] || [] + const hasAgent = agentIdentities.some( + (agent: SavedAgentIdentity) => agent.agentId === agentId, + ) + + if (!hasAgent) { + return { + result: 400, + response: { + pointsAwarded: 0, + totalPoints: account.points.totalPoints || 0, + message: "Error: Agent not linked to this user", + }, + require_reply: false, + extra: {}, + } + } + + // Check if agent points already awarded + const agents = account.points.breakdown?.agents || {} + const agentAlreadyAwarded = agentKey in agents + + if (agentAlreadyAwarded) { + const userPointsWithIdentities = + await this.getUserPointsInternal(userId) + return { + result: 200, + response: { + pointsAwarded: 0, + totalPoints: userPointsWithIdentities.totalPoints, + message: "Agent points already awarded", + }, + require_reply: false, + extra: {}, + } + } + + // Award points by updating the GCR + await this.addPointsToGCR( + userId, + pointValues.LINK_AGENT, + "agents", + agentKey, + referralCode, + ) + + // Get updated points + const updatedPoints = await this.getUserPointsInternal(userId) + + return { + result: 200, + response: { + pointsAwarded: pointValues.LINK_AGENT, + totalPoints: updatedPoints.totalPoints, + message: "Points awarded for linking ERC-8004 agent", + }, + require_reply: false, + extra: {}, + } + } catch (error) { + return { + result: 500, + response: "Error awarding agent points", + require_reply: false, + extra: { + error: + error instanceof Error ? error.message : String(error), + }, + } + } + } + + /** + * Deduct points for unlinking an ERC-8004 agent + * @param userId The user's Demos address + * @param agentId The ERC-8004 agent token ID + * @param chain The chain where agent was registered + * @returns RPCResponse + */ + async deductAgentPoints( + userId: string, + agentId: string, + chain: string, + ): Promise { + try { + // Create agentKey for point tracking: "chain:agentId" + const agentKey = `${chain}:${agentId}` + + // Check if user has points for this agent to deduct + const account = await ensureGCRForUser(userId) + const agents = account.points.breakdown?.agents || {} + const hasAgentPoints = agentKey in agents && agents[agentKey] > 0 + + if (!hasAgentPoints) { + const userPointsWithIdentities = + await this.getUserPointsInternal(userId) + return { + result: 200, + response: { + pointsDeducted: 0, + totalPoints: userPointsWithIdentities.totalPoints, + message: "No agent points to deduct", + }, + require_reply: false, + extra: {}, + } + } + + // Deduct points by updating the GCR + await this.addPointsToGCR( + userId, + -pointValues.LINK_AGENT, + "agents", + agentKey, + ) + + // Get updated points + const updatedPoints = await this.getUserPointsInternal(userId) + + return { + result: 200, + response: { + pointsDeducted: pointValues.LINK_AGENT, + totalPoints: updatedPoints.totalPoints, + message: "Points deducted for unlinking ERC-8004 agent", + }, + require_reply: false, + extra: {}, + } + } catch (error) { + return { + result: 500, + response: "Error deducting agent points", + require_reply: false, + extra: { + error: + error instanceof Error ? error.message : String(error), + }, + } + } + } } diff --git a/src/libs/blockchain/gcr/gcr_routines/GCRIdentityRoutines.ts b/src/libs/blockchain/gcr/gcr_routines/GCRIdentityRoutines.ts index 89b9b6dd3..a4dd33690 100644 --- a/src/libs/blockchain/gcr/gcr_routines/GCRIdentityRoutines.ts +++ b/src/libs/blockchain/gcr/gcr_routines/GCRIdentityRoutines.ts @@ -15,9 +15,12 @@ import { PqcIdentityEdit, SavedXmIdentity, SavedUdIdentity, + SavedAgentIdentity, } from "@/model/entities/types/IdentityTypes" +import { AgentIdentityPayload } from "@kynesyslabs/demosdk/abstraction" import log from "@/utilities/logger" import { IncentiveManager } from "./IncentiveManager" +import { AgentIdentityManager } from "./agentIdentityManager" export default class GCRIdentityRoutines { // SECTION XM Identity Routines @@ -695,6 +698,173 @@ export default class GCRIdentityRoutines { return { success: true, message: "UD identity removed" } } + // SECTION Agent Identity Routines + static async applyAgentIdentityAdd( + editOperation: any, + gcrMainRepository: Repository, + simulate: boolean, + ): Promise { + const payload = editOperation.data as AgentIdentityPayload + + // Validate required fields + if ( + !payload.agentId || + !payload.evmAddress || + !payload.chain || + !payload.proof + ) { + return { + success: false, + message: "Invalid edit operation data: missing required fields (agentId, evmAddress, chain, proof)", + } + } + + // Validate chain - only base.sepolia is currently supported + const canonicalChain = AgentIdentityManager.validateChain(payload.chain) + if (!canonicalChain) { + return { + success: false, + message: `Unsupported agent chain: ${payload.chain}. Only base.sepolia is currently supported.`, + } + } + + // Validate EVM address format + const evmPattern = /^0x[0-9a-fA-F]{40}$/ + if (!evmPattern.test(payload.evmAddress)) { + return { + success: false, + message: `Invalid EVM address format: ${payload.evmAddress}`, + } + } + + // Validate proof structure + if (!payload.proof.type || !payload.proof.message || !payload.proof.signature) { + return { + success: false, + message: "Invalid proof structure: missing required fields", + } + } + + const accountGCR = await ensureGCRForUser(editOperation.account) + + // Initialize agent identities structure if not exists + accountGCR.identities.agent = accountGCR.identities.agent || {} + accountGCR.identities.agent[canonicalChain] = + accountGCR.identities.agent[canonicalChain] || [] + + // Check if agent already exists for this account + const agentExists = accountGCR.identities.agent[canonicalChain].some( + (id: SavedAgentIdentity) => id.agentId === payload.agentId, + ) + + if (agentExists) { + return { + success: false, + message: "Agent already linked to this account", + } + } + + // Create the saved agent identity + const savedAgent: SavedAgentIdentity = { + agentId: payload.agentId, + evmAddress: payload.evmAddress.toLowerCase(), + chain: canonicalChain, + txHash: payload.txHash, + tokenUri: payload.tokenUri, + proof: payload.proof, + timestamp: Date.now(), + resolverUrl: payload.resolverUrl, + } + + accountGCR.identities.agent[canonicalChain].push(savedAgent) + + if (!simulate) { + await gcrMainRepository.save(accountGCR) + + /** + * Check if this is the first connection for this agent + */ + const isFirst = await this.isFirstConnection( + "agent", + { agentId: payload.agentId, chain: canonicalChain }, + gcrMainRepository, + editOperation.account, + ) + + /** + * Award incentive points for agent linking + */ + if (isFirst) { + await IncentiveManager.agentLinked( + accountGCR.pubkey, + payload.agentId, + canonicalChain, + editOperation.referralCode, + ) + } + } + + return { success: true, message: "Agent identity added" } + } + + static async applyAgentIdentityRemove( + editOperation: any, + gcrMainRepository: Repository, + simulate: boolean, + ): Promise { + const { agentId, chain } = editOperation.data + + if (!agentId || !chain) { + return { success: false, message: "Invalid edit operation data" } + } + + const accountGCR = await gcrMainRepository.findOneBy({ + pubkey: editOperation.account, + }) + + if (!accountGCR) { + return { success: false, message: "Account not found" } + } + + if ( + !accountGCR.identities || + !accountGCR.identities.agent || + !accountGCR.identities.agent[chain] + ) { + return { + success: false, + message: "No agent identities found for this chain", + } + } + + const agentExists = accountGCR.identities.agent[chain].some( + (id: SavedAgentIdentity) => id.agentId === agentId, + ) + + if (!agentExists) { + return { success: false, message: "Agent not found" } + } + + accountGCR.identities.agent[chain] = accountGCR.identities.agent[ + chain + ].filter((id: SavedAgentIdentity) => id.agentId !== agentId) + + if (!simulate) { + await gcrMainRepository.save(accountGCR) + + /** + * Deduct incentive points for agent unlinking + */ + await IncentiveManager.agentUnlinked( + accountGCR.pubkey, + agentId, + chain, + ) + } + + return { success: true, message: "Agent identity removed" } + } + static async applyAwardPoints( editOperation: any, // GCREditIdentity but typed as any due to union type constraints gcrMainRepository: Repository, @@ -839,6 +1009,20 @@ export default class GCRIdentityRoutines { simulate, ) break + case "agentadd": + result = await this.applyAgentIdentityAdd( + identityEdit, + gcrMainRepository, + simulate, + ) + break + case "agentremove": + result = await this.applyAgentIdentityRemove( + identityEdit, + gcrMainRepository, + simulate, + ) + break case "pointsadd": result = await this.applyAwardPoints( identityEdit, @@ -864,17 +1048,37 @@ export default class GCRIdentityRoutines { } private static async isFirstConnection( - type: "twitter" | "github" | "web3" | "telegram" | "discord" | "ud", + type: "twitter" | "github" | "web3" | "telegram" | "discord" | "ud" | "agent", data: { userId?: string // for twitter/github/discord - chain?: string // for web3 + chain?: string // for web3 and agent subchain?: string // for web3 address?: string // for web3 domain?: string // for ud + agentId?: string // for agent }, gcrMainRepository: Repository, currentAccount?: string, ): Promise { + if (type === "agent") { + /** + * Check if this agent exists anywhere + */ + const result = await gcrMainRepository + .createQueryBuilder("gcr") + .where( + "EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(gcr.identities->'agent'->:chain, '[]'::jsonb)) AS agent_id WHERE agent_id->>'agentId' = :agentId)", + { chain: data.chain, agentId: data.agentId }, + ) + .andWhere("gcr.pubkey != :currentAccount", { currentAccount }) + .getOne() + + /** + * Return true if no account has this agent + */ + return !result + } + if (type !== "web3" && type !== "ud") { // Handle web2 identity types: twitter, github, telegram, discord const queryTemplate = ` diff --git a/src/libs/blockchain/gcr/gcr_routines/IncentiveManager.ts b/src/libs/blockchain/gcr/gcr_routines/IncentiveManager.ts index b056fa3f2..77f67e488 100644 --- a/src/libs/blockchain/gcr/gcr_routines/IncentiveManager.ts +++ b/src/libs/blockchain/gcr/gcr_routines/IncentiveManager.ts @@ -161,4 +161,32 @@ export class IncentiveManager { ): Promise { return await this.pointSystem.deductUdDomainPoints(userId, domain) } + + /** + * Hook to be called after ERC-8004 agent linking + */ + static async agentLinked( + userId: string, + agentId: string, + chain: string, + referralCode?: string, + ): Promise { + return await this.pointSystem.awardAgentPoints( + userId, + agentId, + chain, + referralCode, + ) + } + + /** + * Hook to be called after ERC-8004 agent unlinking + */ + static async agentUnlinked( + userId: string, + agentId: string, + chain: string, + ): Promise { + return await this.pointSystem.deductAgentPoints(userId, agentId, chain) + } } diff --git a/src/libs/blockchain/gcr/gcr_routines/agentIdentityManager.ts b/src/libs/blockchain/gcr/gcr_routines/agentIdentityManager.ts new file mode 100644 index 000000000..c0adf796c --- /dev/null +++ b/src/libs/blockchain/gcr/gcr_routines/agentIdentityManager.ts @@ -0,0 +1,406 @@ +import { ethers, JsonRpcProvider } from "ethers" + +import log from "@/utilities/logger" +import ensureGCRForUser from "./ensureGCRForUser" +import { SavedAgentIdentity } from "@/model/entities/types/IdentityTypes" +import { + DemosOwnershipProof, + AgentIdentityAssignPayload, +} from "@kynesyslabs/demosdk/abstraction" +import { hexToUint8Array, ucrypto } from "@kynesyslabs/demosdk/encryption" + +/** + * AgentIdentityManager - Handles ERC-8004 Agent identity verification and storage + * + * Verification Flow: + * 1. User has an ERC-8004 agent NFT on Base Sepolia + * 2. User signs ownership proof with their Demos wallet + * 3. Verify EVM address owns the agent NFT on-chain + * 4. Verify the Demos ownership proof signature + * 5. Store agent identity in GCR database + * + * Pattern: Follows UD/XM signature-based verification + */ + +// ERC-8004 IdentityRegistry contract on Base Sepolia +const AGENT_REGISTRY_ADDRESS = "0x8004AA63c570c570eBF15376c0dB199918BFe9Fb" + +// Canonical chain identifier for agent identities +const CANONICAL_CHAIN = "base.sepolia" + +// Accepted chain aliases that map to the canonical chain +const CHAIN_ALIASES: Record = { + "base.sepolia": CANONICAL_CHAIN, + "base-sepolia": CANONICAL_CHAIN, + "basesepolia": CANONICAL_CHAIN, +} + +// Base Sepolia configuration +const BASE_SEPOLIA_CONFIG = { + chainId: 84532, + chain: "base", + subchain: "sepolia", + rpc: "https://sepolia.base.org", +} + +// Multiple RPC endpoints for failover (public endpoints) +const BASE_SEPOLIA_RPC_ENDPOINTS = [ + "https://sepolia.base.org", + "https://base-sepolia-rpc.publicnode.com", + "https://base-sepolia.blockpi.network/v1/rpc/public", +] + +// Timeout for RPC requests in milliseconds (5 seconds) +const RPC_TIMEOUT_MS = 5000 + +const registryAbi = [ + "function ownerOf(uint256 tokenId) external view returns (address)", + "function tokenURI(uint256 tokenId) external view returns (string)", +] + +export class AgentIdentityManager { + constructor() { } + + /** + * Validate and normalize chain identifier to canonical form. + * Returns the canonical chain or null if invalid. + * + * @param chain - The chain identifier to validate + * @returns The canonical chain identifier or null if invalid + */ + static validateChain(chain: string): string | null { + if (!chain) return null + const normalized = chain.toLowerCase().trim() + return CHAIN_ALIASES[normalized] || null + } + + /** + * Get the canonical chain identifier + */ + static getCanonicalChain(): string { + return CANONICAL_CHAIN + } + + /** + * Verify agent NFT ownership on Base Sepolia + * + * @param agentId - The ERC-8004 token ID + * @param expectedOwner - The expected EVM address owner + * @returns True if the address owns the agent, false otherwise + */ + static async verifyAgentOwnership( + agentId: string, + expectedOwner: string, + ): Promise<{ success: boolean; message: string; actualOwner?: string }> { + for (const rpcUrl of BASE_SEPOLIA_RPC_ENDPOINTS) { + try { + // Create provider with fetch options including timeout + const provider = new JsonRpcProvider(rpcUrl, undefined, { + staticNetwork: true, + batchMaxCount: 1, + }) + + const contract = new ethers.Contract( + AGENT_REGISTRY_ADDRESS, + registryAbi, + provider, + ) + + // Wrap the call with a timeout to prevent hanging + const ownerPromise = contract.ownerOf(agentId) + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error("RPC request timeout")), RPC_TIMEOUT_MS) + ) + const owner = await Promise.race([ownerPromise, timeoutPromise]) + + if (owner.toLowerCase() !== expectedOwner.toLowerCase()) { + return { + success: false, + message: `Agent NFT ${agentId} is owned by ${owner}, not ${expectedOwner}`, + actualOwner: owner, + } + } + + return { + success: true, + message: `Verified ownership of agent ${agentId} by ${owner}`, + actualOwner: owner, + } + } catch (error) { + log.debug( + `Failed to verify agent ownership via ${rpcUrl}: ${error}`, + ) + continue + } + } + + return { + success: false, + message: `Failed to verify agent ownership on all RPC endpoints. Agent ${agentId} may not exist.`, + } + } + + /** + * Verify Demos ownership proof signature + * + * The proof contains: + * - message: "I authorize EVM address {evmAddress} to register an ERC-8004 agent for Demos identity {demosPublicKey}. Timestamp: {timestamp}" + * - signature: Signed by Demos wallet (ed25519 or other supported algorithm) + * - demosPublicKey: The Demos identity's ed25519 public key + * + * @param proof - The ownership proof + * @param sender - The sender's ed25519 address from transaction + * @returns Verification result + */ + static async verifyOwnershipProof( + proof: DemosOwnershipProof, + sender: string, + ): Promise<{ success: boolean; message: string }> { + try { + // Verify the proof type + if (proof.type !== "demos-signature") { + return { + success: false, + message: `Invalid proof type: ${proof.type}, expected "demos-signature"`, + } + } + + // Verify the message contains the correct Demos public key + // Expected format: "I authorize EVM address {evmAddress} to register an ERC-8004 agent for Demos identity {demosPublicKey}..." + const demosIdentityRegex = + /for Demos identity (?:0x)?([a-fA-F0-9]+)/ + const match = proof.message.match(demosIdentityRegex) + + if (!match) { + return { + success: false, + message: "Proof message does not contain Demos identity", + } + } + + // Normalize both for comparison (remove 0x prefix, lowercase) + const normalizedMatch = match[1].replace(/^0x/i, "").toLowerCase() + const normalizedSender = sender.replace(/^0x/i, "").toLowerCase() + + if (normalizedMatch !== normalizedSender) { + return { + success: false, + message: `Proof Demos identity ${match[1]} does not match sender ${sender}`, + } + } + + // Verify the proof demosPublicKey matches sender + const normalizedProofKey = proof.demosPublicKey + .replace(/^0x/i, "") + .toLowerCase() + + if (normalizedProofKey !== normalizedSender) { + return { + success: false, + message: `Proof demosPublicKey ${proof.demosPublicKey} does not match sender ${sender}`, + } + } + + // Verify the signature + let signatureHex: string + let algorithm: string + + if (typeof proof.signature === "string") { + signatureHex = proof.signature + algorithm = "ed25519" // Default to ed25519 + } else { + signatureHex = proof.signature.data + algorithm = proof.signature.type + } + + // Verify using ucrypto with object signature + const isValid = await ucrypto.verify({ + algorithm: algorithm as "ed25519" | "ml-dsa" | "falcon", + message: new TextEncoder().encode(proof.message), + signature: hexToUint8Array(signatureHex.replace(/^0x/i, "")), + publicKey: hexToUint8Array(proof.demosPublicKey.replace(/^0x/i, "")), + }) + + if (!isValid) { + return { + success: false, + message: "Ownership proof signature verification failed", + } + } + + return { + success: true, + message: "Ownership proof verified successfully", + } + } catch (error) { + log.error(`Error verifying ownership proof: ${error}`) + return { + success: false, + message: `Ownership proof verification error: ${error}`, + } + } + } + + /** + * Verify agent identity payload + * + * This method verifies: + * 1. The EVM address owns the agent NFT on Base Sepolia + * 2. The ownership proof signature is valid + * 3. The proof contains the correct Demos public key + * + * @param payload - The agent identity payload from transaction + * @param sender - The ed25519 address from transaction body + * @returns Verification result with success status and message + */ + static async verifyPayload( + payload: AgentIdentityAssignPayload, + sender: string, + ): Promise<{ success: boolean; message: string }> { + try { + const { agentId, evmAddress, chain, txHash, tokenUri, proof } = + payload.payload + + // Validate required fields + if (!agentId) { + return { + success: false, + message: "Agent ID is required", + } + } + + if (!evmAddress) { + return { + success: false, + message: "EVM address is required", + } + } + + // Validate chain - must be base.sepolia or known alias + const canonicalChain = this.validateChain(chain) + if (!canonicalChain) { + return { + success: false, + message: `Invalid chain: ${chain}. Only base.sepolia is currently supported.`, + } + } + + if (!proof) { + return { + success: false, + message: "Ownership proof is required", + } + } + + // Validate EVM address format + const evmPattern = /^0x[0-9a-fA-F]{40}$/ + if (!evmPattern.test(evmAddress)) { + return { + success: false, + message: `Invalid EVM address format: ${evmAddress}`, + } + } + + // Verify proof EVM address matches payload EVM address + if (proof.evmAddress.toLowerCase() !== evmAddress.toLowerCase()) { + return { + success: false, + message: `Proof EVM address ${proof.evmAddress} does not match payload EVM address ${evmAddress}`, + } + } + + // Step 1: Verify ownership proof signature + log.debug( + `Verifying ownership proof for agent ${agentId} by Demos identity ${sender}`, + ) + const proofResult = await this.verifyOwnershipProof(proof, sender) + if (!proofResult.success) { + return proofResult + } + + // Step 2: Verify on-chain agent NFT ownership + log.debug( + `Verifying on-chain ownership of agent ${agentId} by ${evmAddress}`, + ) + const ownershipResult = await this.verifyAgentOwnership( + agentId, + evmAddress, + ) + if (!ownershipResult.success) { + return ownershipResult + } + + log.info( + `Agent identity verified: agent=${agentId}, evmAddress=${evmAddress}, demos=${sender}, chain=${chain}`, + ) + + return { + success: true, + message: `Verified agent ${agentId} ownership by ${evmAddress} linked to Demos identity ${sender}`, + } + } catch (error) { + log.error(`Error verifying agent payload: ${error}`) + return { + success: false, + message: `Verification error: ${error}`, + } + } + } + + /** + * Get agent identities for a Demos address + * + * @param address - The Demos address + * @param chain - Optional chain filter (e.g., "base.sepolia") + * @returns Array of saved agent identities + */ + static async getAgentIdentities( + address: string, + chain?: string, + ): Promise { + const gcr = await ensureGCRForUser(address) + + // Defensive initialization for backward compatibility + if (!gcr.identities.agent) { + return [] + } + + if (chain) { + return gcr.identities.agent[chain] || [] + } + + // Return all agent identities across all chains + const allAgents: SavedAgentIdentity[] = [] + for (const chainKey of Object.keys(gcr.identities.agent)) { + allAgents.push(...(gcr.identities.agent[chainKey] || [])) + } + + return allAgents + } + + /** + * Get all identities for a Demos address + * + * @param address - The Demos address + * @param key - Optional key to get specific identity type + * @returns Identities object or specific identity type + */ + static async getIdentities(address: string, key?: string): Promise { + const gcr = await ensureGCRForUser(address) + if (key) { + return gcr.identities[key] + } + + return gcr.identities + } + + /** + * Get configuration for agent identity + */ + static getConfig() { + return { + registryAddress: AGENT_REGISTRY_ADDRESS, + ...BASE_SEPOLIA_CONFIG, + } + } +} diff --git a/src/libs/blockchain/gcr/gcr_routines/identityManager.ts b/src/libs/blockchain/gcr/gcr_routines/identityManager.ts index 450189754..7fc1f012e 100644 --- a/src/libs/blockchain/gcr/gcr_routines/identityManager.ts +++ b/src/libs/blockchain/gcr/gcr_routines/identityManager.ts @@ -278,11 +278,10 @@ export default class IdentityManager { return { success: true, - message: `Signature proof${ - payloads.length > 1 ? "s" : "" - } verified. ${JSON.stringify( - payloads.map(p => p.algorithm), - )} identities assigned`, + message: `Signature proof${payloads.length > 1 ? "s" : "" + } verified. ${JSON.stringify( + payloads.map(p => p.algorithm), + )} identities assigned`, } } @@ -330,7 +329,7 @@ export default class IdentityManager { */ static async getIdentities( address: string, - key?: "xm" | "web2" | "pqc" | "ud", + key?: "xm" | "web2" | "pqc" | "ud" | "agent", ): Promise { const gcr = await ensureGCRForUser(address) if (key) { @@ -343,4 +342,29 @@ export default class IdentityManager { static async getUDIdentities(address: string) { return await this.getIdentities(address, "ud") } + + /** + * Get the agent identities related to a demos address + * @param address - The address to get the agent identities of + * @param chain - Optional chain filter (e.g., "base.sepolia") + * @returns The agent identities of the address + */ + static async getAgentIdentities(address: string, chain?: string) { + const data = await this.getIdentities(address, "agent") + if (!data) { + return [] + } + + if (chain) { + return data[chain] || [] + } + + // Return all agent identities across all chains + const allAgents: any[] = [] + for (const chainKey of Object.keys(data)) { + allAgents.push(...(data[chainKey] || [])) + } + + return allAgents + } } diff --git a/src/libs/network/routines/transactions/handleIdentityRequest.ts b/src/libs/network/routines/transactions/handleIdentityRequest.ts index 72d502bd3..5217f2f4f 100644 --- a/src/libs/network/routines/transactions/handleIdentityRequest.ts +++ b/src/libs/network/routines/transactions/handleIdentityRequest.ts @@ -2,13 +2,14 @@ import { IdentityPayload, InferFromSignaturePayload, Web2CoreTargetIdentityPayload, - UDIdentityAssignPayload, + AgentIdentityAssignPayload, } from "@kynesyslabs/demosdk/abstraction" import { verifyWeb2Proof } from "@/libs/abstraction" import { Transaction } from "@kynesyslabs/demosdk/types" import { PqcIdentityAssignPayload } from "@kynesyslabs/demosdk/abstraction" import IdentityManager from "@/libs/blockchain/gcr/gcr_routines/identityManager" import { UDIdentityManager } from "@/libs/blockchain/gcr/gcr_routines/udIdentityManager" +import { AgentIdentityManager } from "@/libs/blockchain/gcr/gcr_routines/agentIdentityManager" import { Referrals } from "@/features/incentive/referrals" import log from "@/utilities/logger" import ensureGCRForUser from "@/libs/blockchain/gcr/gcr_routines/ensureGCRForUser" @@ -95,10 +96,18 @@ export default async function handleIdentityRequest( payload.payload as Web2CoreTargetIdentityPayload, sender, ) + case "agent_identity_assign": + // NOTE: Agent identity follows signature-based verification like UD + // Verifies: 1) Ownership proof signature, 2) On-chain NFT ownership + return await AgentIdentityManager.verifyPayload( + payload as AgentIdentityAssignPayload, + sender, + ) case "xm_identity_remove": case "pqc_identity_remove": case "web2_identity_remove": case "ud_identity_remove": + case "agent_identity_remove": return { success: true, message: "Identity removed", diff --git a/src/model/entities/GCRv2/GCR_Main.ts b/src/model/entities/GCRv2/GCR_Main.ts index a12eb97e5..bad9a0fe8 100644 --- a/src/model/entities/GCRv2/GCR_Main.ts +++ b/src/model/entities/GCRv2/GCR_Main.ts @@ -34,6 +34,7 @@ export class GCRMain { telegram: number } udDomains?: { [domain: string]: number } // Optional for backward compatibility with historical records + agents?: { [agentKey: string]: number } referrals: number demosFollow: number weeklyChallenge?: Array<{ diff --git a/src/model/entities/types/IdentityTypes.ts b/src/model/entities/types/IdentityTypes.ts index fb5dc770f..18fdadfc6 100644 --- a/src/model/entities/types/IdentityTypes.ts +++ b/src/model/entities/types/IdentityTypes.ts @@ -1,4 +1,5 @@ import { Web2GCRData, SignatureType } from "@kynesyslabs/demosdk/types" +import { DemosOwnershipProof } from "@kynesyslabs/demosdk/abstraction" export interface SavedXmIdentity { // NOTE: We don't store the message here @@ -52,6 +53,28 @@ export interface SavedUdIdentity { registryType: "UNS" | "CNS" // Which registry was used } +/** + * ERC-8004 Agent Identity saved in the GCR + * + * Links an ERC-8004 agent NFT (registered on Base Sepolia) to a Demos identity. + * The agent NFT represents an AI agent's on-chain identity. + * + * Requirements: + * - User must have an EVM wallet linked to their Demos identity + * - The EVM wallet must own the ERC-8004 agent NFT + * - User must sign ownership proof with their Demos wallet + */ +export interface SavedAgentIdentity { + agentId: string // ERC-8004 token ID + evmAddress: string // EVM address that owns the agent NFT + chain: string // Chain where agent is registered (e.g., "base.sepolia") + txHash: string // Transaction hash of agent registration + tokenUri: string // Token URI pointing to agent card metadata + proof: DemosOwnershipProof // Ownership proof signed by Demos wallet + timestamp: number // When the identity was linked + resolverUrl?: string // Optional resolver URL for the agent +} + export type StoredIdentities = { xm: { [chain: string]: { @@ -67,4 +90,8 @@ export type StoredIdentities = { [algorithm: string]: SavedPqcIdentity[] } ud: SavedUdIdentity[] // Unstoppable Domains identities + agent: { + // A mapping of chain (e.g., "base.sepolia") to array of agent identities + [chain: string]: SavedAgentIdentity[] + } }