Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
289 changes: 281 additions & 8 deletions src/abstraction/Identities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import {
FindDemosIdByWeb2IdentityQuery,
FindDemosIdByWeb3IdentityQuery,
UDIdentityPayload,
AgentIdentityPayload,
DemosOwnershipProof,
} from "@/types/abstraction"
import { UnifiedDomainResolution } from "@/abstraction/types/UDResolution"
import { Demos } from "@/websdk/demosclass"
Expand Down Expand Up @@ -74,7 +76,7 @@ export class Identities {
*/
private async inferIdentity(
demos: Demos,
context: "xm" | "web2" | "pqc" | "ud",
context: "xm" | "web2" | "pqc" | "ud" | "agent",
payload: any,
): Promise<RPCResponseWithValidityData> {
if (context === "web2") {
Expand All @@ -89,11 +91,10 @@ export class Identities {
)
) {
// construct informative error message
const errorMessage = `Invalid ${
payload.context
} proof format. Supported formats are: ${this.formats.web2[
payload.context
].join(", ")}`
const errorMessage = `Invalid ${payload.context
} proof format. Supported formats are: ${this.formats.web2[
payload.context
].join(", ")}`
throw new Error(errorMessage)
}
}
Expand Down Expand Up @@ -133,7 +134,7 @@ export class Identities {
*/
private async removeIdentity(
demos: Demos,
context: "xm" | "web2" | "pqc" | "ud",
context: "xm" | "web2" | "pqc" | "ud" | "agent",
payload: any,
): Promise<RPCResponseWithValidityData> {
const tx = DemosTransactions.empty()
Expand Down Expand Up @@ -957,7 +958,7 @@ export class Identities {

throw new Error(
`Unrecognized address format: ${address}. ` +
`Expected EVM (0x...) or Solana (base58) address.`,
`Expected EVM (0x...) or Solana (base58) address.`,
)
}

Expand Down Expand Up @@ -1113,4 +1114,276 @@ export class Identities {
async getUDIdentities(demos: Demos, address?: string) {
return await this.getIdentities(demos, "getUDIdentities", address)
}

// SECTION: ERC-8004 Agent Identities

/**
* ERC-8004 IdentityRegistry contract address on Base Sepolia
*/
static readonly AGENT_REGISTRY_ADDRESS =
"0x8004AA63c570c570eBF15376c0dB199918BFe9Fb"

/**
* Base Sepolia chain configuration for agent identity
*/
static readonly AGENT_CHAIN_CONFIG = {
chainId: 84532,
chain: "base",
subchain: "sepolia",
rpc: "https://sepolia.base.org",
}

/**
* Generate the ownership proof message for ERC-8004 agent registration.
*
* This message must be signed by the user's Demos wallet to authorize
* linking an EVM address (which owns the agent NFT) to their Demos identity.
*
* @param demosPublicKey The user's Demos public key (hex string)
* @param agentId The ERC-8004 token ID being claimed
* @param evmAddress The EVM address that owns the agent NFT
* @returns Object containing the message and timestamp
*/
generateAgentOwnershipMessage(
demosPublicKey: string,
agentId: string,
evmAddress: string,
): { message: string; timestamp: number } {
const timestamp = Date.now()
const message = `I authorize EVM address ${evmAddress} to register ERC-8004 agent #${agentId} for Demos identity ${demosPublicKey}. Timestamp: ${timestamp}`
return { message, timestamp }
}

/**
* Create an ownership proof by signing the ownership message with the Demos wallet.
*
* @param demos A Demos instance to sign the message
* @param agentId The ERC-8004 token ID being claimed
* @param evmAddress The EVM address that owns the agent NFT
* @returns The complete ownership proof object
*/
async createAgentOwnershipProof(
demos: Demos,
agentId: string,
evmAddress: string,
): Promise<DemosOwnershipProof> {
const demosPublicKey = await demos.getEd25519Address()
const { message, timestamp } = this.generateAgentOwnershipMessage(
demosPublicKey,
agentId,
evmAddress,
)

const signature = await demos.crypto.sign(
demos.algorithm,
new TextEncoder().encode(message),
)

return {
type: "demos-signature",
message,
signature: {
type: demos.algorithm,
data: uint8ArrayToHex(signature.signature),
},
demosPublicKey,
agentId,
evmAddress,
timestamp,
}
}

/**
* Add an ERC-8004 agent identity to the GCR.
*
* This links an ERC-8004 agent NFT (registered on Base Sepolia) to a Demos identity.
*
* Requirements:
* - User must have an EVM wallet linked to their Demos identity
* - That EVM wallet must own the agent NFT
* - User must sign an ownership proof with their Demos wallet
*
* @param demos A Demos instance to communicate with the RPC
* @param payload The agent identity payload containing:
* - agentId: The ERC-8004 token ID
* - evmAddress: The EVM address owning the agent
* - chain: The chain where agent is registered (e.g., "base.sepolia")
* - txHash: The registration transaction hash
* - tokenUri: The token URI pointing to agent card metadata
* - proof: The ownership proof signed by Demos wallet
* @param referralCode Optional referral code for points
* @returns The validity data of the identity transaction
*
* @example
* ```typescript
* const identities = new Identities()
*
* // Create ownership proof
* const proof = await identities.createAgentOwnershipProof(demos, evmAddress)
*
* // Add agent identity
* await identities.addAgentIdentity(demos, {
* agentId: "123",
* evmAddress: "0x...",
* chain: "base.sepolia",
* txHash: "0x...",
* tokenUri: "ipfs://...",
* proof
* })
* ```
*/
async addAgentIdentity(
demos: Demos,
payload: AgentIdentityPayload,
referralCode?: string,
): Promise<RPCResponseWithValidityData> {
// Validate the payload
if (!payload.agentId) {
throw new Error("Agent ID is required")
}
if (!payload.evmAddress) {
throw new Error("EVM address is required")
}
if (!payload.proof) {
throw new Error("Ownership proof is required")
}

// Verify the EVM address format
const evmPattern = /^0x[0-9a-fA-F]{40}$/
if (!evmPattern.test(payload.evmAddress)) {
throw new Error(
`Invalid EVM address format: ${payload.evmAddress}`,
)
}

// Verify the proof contains the correct EVM address
if (
payload.proof.evmAddress.toLowerCase() !==
payload.evmAddress.toLowerCase()
) {
throw new Error(
"Ownership proof EVM address doesn't match payload EVM address",
)
}
Comment on lines +1321 to +1348
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate full proof-to-payload binding in addAgentIdentity.

Current checks only bind evmAddress. Add at least proof.agentId === payload.agentId (and ideally proof.demosPublicKey against the connected identity) to prevent inconsistent proof/payload combinations from passing local validation.

Suggested fix
         // Verify the proof contains the correct EVM address
         if (
             payload.proof.evmAddress.toLowerCase() !==
             payload.evmAddress.toLowerCase()
         ) {
             throw new Error(
                 "Ownership proof EVM address doesn't match payload EVM address",
             )
         }
+
+        if (payload.proof.agentId !== payload.agentId) {
+            throw new Error(
+                "Ownership proof agentId doesn't match payload agentId",
+            )
+        }
+
+        const connectedDemosKey = await demos.getEd25519Address()
+        if (payload.proof.demosPublicKey !== connectedDemosKey) {
+            throw new Error(
+                "Ownership proof demosPublicKey doesn't match connected identity",
+            )
+        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/abstraction/Identities.ts` around lines 1321 - 1348, The addAgentIdentity
payload validation currently only checks evmAddress; update addAgentIdentity to
also assert that payload.proof.agentId === payload.agentId (throw a clear Error
if mismatched) and, if available, validate payload.proof.demosPublicKey against
the connected identity's public key (e.g. compare payload.proof.demosPublicKey
to connectedIdentity.publicKey or payload.demosPublicKey) to prevent mismatched
proof/payload combos; place these checks alongside the existing
proof/evenAddress validation and include descriptive error messages referencing
agentId and demosPublicKey.


return await this.inferIdentity(demos, "agent", {
...payload,
referralCode,
})
}

/**
* Remove an ERC-8004 agent identity from the GCR.
*
* @param demos A Demos instance to communicate with the RPC
* @param agentId The ERC-8004 token ID to remove
* @param chain The chain where the agent is registered (e.g., "base.sepolia")
* @returns The validity data response from the RPC
*/
async removeAgentIdentity(
demos: Demos,
agentId: string,
chain: string = "base.sepolia",
): Promise<RPCResponseWithValidityData> {
return await this.removeIdentity(demos, "agent", { agentId, chain })
}

/**
* Get the ERC-8004 agent identities associated with an address.
*
* @param demos A Demos instance to communicate with the RPC
* @param address The Demos address to get agent identities for.
* Defaults to the connected wallet's address.
* @returns The agent identities associated with the address.
*/
async getAgentIdentities(demos: Demos, address?: string) {
return await this.getIdentities(demos, "getAgentIdentities", address)
}

/**
* Verify that an EVM address owns a specific ERC-8004 agent NFT.
*
* This is a client-side helper to verify ownership before submitting
* the identity to the network. The node will also verify this.
*
* @param agentId The ERC-8004 token ID
* @param expectedOwner The expected EVM address owner
* @returns True if the address owns the agent, false otherwise
*/
async verifyAgentOwnership(
agentId: string,
expectedOwner: string,
): Promise<boolean> {
const registryAbi = [
"function ownerOf(uint256 tokenId) external view returns (address)",
]

try {
const provider = new ethers.JsonRpcProvider(
Identities.AGENT_CHAIN_CONFIG.rpc,
)
const contract = new ethers.Contract(
Identities.AGENT_REGISTRY_ADDRESS,
registryAbi,
provider,
)

const owner = await contract.ownerOf(agentId)
return owner.toLowerCase() === expectedOwner.toLowerCase()
} catch (error: any) {
// Token doesn't exist or other error
return false
}
Comment on lines +1402 to +1417
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Do not collapse all verification errors into false.

Returning false for every exception makes transient RPC/network issues indistinguishable from real non-ownership, which can mislead callers and UI flows.

Suggested fix
         try {
@@
             const owner = await contract.ownerOf(agentId)
             return owner.toLowerCase() === expectedOwner.toLowerCase()
         } catch (error: any) {
-            // Token doesn't exist or other error
-            return false
+            const message = String(error?.message || "")
+            if (
+                message.toLowerCase().includes("nonexistent token") ||
+                message.toLowerCase().includes("erc721")
+            ) {
+                return false
+            }
+            throw new Error(`Failed to verify agent ownership: ${message}`)
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/abstraction/Identities.ts` around lines 1402 - 1417, The current
try/catch around creating the JsonRpcProvider and calling
contract.ownerOf(agentId) swallows all errors and returns false for transient
RPC/network issues; instead, narrow the catch: call new
ethers.JsonRpcProvider(...) and contract.ownerOf(agentId) as before, but in the
catch inspect the error (e.g., error.code or error.message) and only return
false for the specific ERC-721 "nonexistent token" error (or a deterministic
owner-query-not-found message), while rethrowing other errors so callers can
handle RPC/network failures; reference the symbols provider, contract, ownerOf,
Identities.AGENT_REGISTRY_ADDRESS and Identities.AGENT_CHAIN_CONFIG.rpc when
updating the logic.

}

/**
* Fetch the agent card metadata from the token URI.
*
* @param agentId The ERC-8004 token ID
* @returns The agent card metadata or null if not found
*/
async getAgentCard(agentId: string): Promise<any | null> {
const registryAbi = [
"function tokenURI(uint256 tokenId) external view returns (string)",
]

try {
const provider = new ethers.JsonRpcProvider(
Identities.AGENT_CHAIN_CONFIG.rpc,
)
const contract = new ethers.Contract(
Identities.AGENT_REGISTRY_ADDRESS,
registryAbi,
provider,
)

const tokenUri = await contract.tokenURI(agentId)

// Handle different URI formats
if (tokenUri.startsWith("data:application/json;base64,")) {
// Base64 encoded JSON (data URI)
const base64 = tokenUri.replace(
"data:application/json;base64,",
"",
)
const json = Buffer.from(base64, "base64").toString("utf-8")
return JSON.parse(json)
} else if (tokenUri.startsWith("ipfs://")) {
// IPFS URI - fetch via gateway
const ipfsHash = tokenUri.replace("ipfs://", "")
const response = await axios.get(
`https://ipfs.io/ipfs/${ipfsHash}`,
)
Comment on lines +1467 to +1473
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Normalize IPFS URIs before gateway resolution.

replace("ipfs://", "") fails for common ipfs://ipfs/<cid> URIs and can produce invalid gateway URLs.

Suggested fix
             } else if (tokenUri.startsWith("ipfs://")) {
                 // IPFS URI - fetch via gateway
-                const ipfsHash = tokenUri.replace("ipfs://", "")
+                const ipfsPath = tokenUri.replace(/^ipfs:\/\//, "")
+                const normalizedIpfsPath = ipfsPath.replace(/^ipfs\//, "")
                 const response = await axios.get(
-                    `https://ipfs.io/ipfs/${ipfsHash}`,
+                    `https://ipfs.io/ipfs/${normalizedIpfsPath}`,
                     { timeout: 10_000 },
                 )
                 return response.data
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/abstraction/Identities.ts` around lines 1467 - 1473, The IPFS gateway
handling in the tokenUri branch should normalize various ipfs:// forms (e.g.,
ipfs://ipfs/<cid>, ipfs:///<cid>) before building the gateway URL; instead of
using tokenUri.replace("ipfs://", ""), parse tokenUri to strip the leading
scheme and any leading "ipfs/" segment or extra slashes to produce a clean
ipfsHash/path, then use that normalized value when calling axios.get; update the
logic around tokenUri and the ipfsHash variable in the Identites.ts block
handling tokenUri.startsWith("ipfs://") so the gateway URL is always valid for
common IPFS URI variants.

return response.data
} else if (tokenUri.startsWith("http")) {
// HTTP(S) URI
const response = await axios.get(tokenUri)
return response.data
}

return null
} catch (error) {
return null
}
}
}
18 changes: 16 additions & 2 deletions src/abstraction/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,14 @@ import {
FindDemosIdByWeb3IdentityQuery,
TelegramAttestationPayload,
TelegramSignedAttestation,
DiscordProof
DiscordProof,
AgentIdentityPayload,
AgentIdentityAssignPayload,
AgentIdentityRemovePayload,
AgentIdentityPayloadType,
DemosOwnershipProof,
ERC8004AgentCard,
ERC8004Endpoint,
} from "@/types/abstraction"

export {
Expand All @@ -54,5 +61,12 @@ export {
FindDemosIdByWeb3IdentityQuery,
TelegramAttestationPayload,
TelegramSignedAttestation,
DiscordProof
DiscordProof,
AgentIdentityPayload,
AgentIdentityAssignPayload,
AgentIdentityRemovePayload,
AgentIdentityPayloadType,
DemosOwnershipProof,
ERC8004AgentCard,
ERC8004Endpoint,
}
Loading
Loading