diff --git a/CLAUDE.md b/CLAUDE.md index 024c34a44..066241614 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,6 +112,7 @@ The app uses a sophisticated transaction flow system centered around: - Prefer small, pure functions outside of React hooks - Transaction logic should be in `transaction/` files, not components - Use path aliases: `@app/` for src/, `@public/` for public/ +- **No barrel exports for internal code** - Do not create `index.ts` files that re-export from other modules. Import directly from the specific file (e.g., `import { foo } from './utils/foo'` not `import { foo } from './utils'`). Barrel exports add unnecessary indirection and can cause circular dependency issues. ### Local Development 1. Install Docker for test environment diff --git a/public/locales/en/profile.json b/public/locales/en/profile.json index 59efa2ae1..8e4b093e1 100644 --- a/public/locales/en/profile.json +++ b/public/locales/en/profile.json @@ -8,6 +8,7 @@ "yourProfile": "Your profile", "accounts": "Accounts", "addresses": "Addresses", + "agents": "AI Agents", "otherRecords": "Other Records", "verifications": "Verifications", "editProfile": "Edit Profile", diff --git a/src/components/pages/profile/AgentProfileButton.tsx b/src/components/pages/profile/AgentProfileButton.tsx new file mode 100644 index 000000000..731db08b4 --- /dev/null +++ b/src/components/pages/profile/AgentProfileButton.tsx @@ -0,0 +1,66 @@ +import { useMemo } from 'react' +import { useCopyToClipboard } from 'react-use' + +import { CopySVG, Dropdown, RecordItem, UpRightArrowSVG, VerticalDotsSVG } from '@ensdomains/thorin' +import { DropdownItem } from '@ensdomains/thorin/dist/types/components/molecules/Dropdown/Dropdown' + +import { AgentRegistrationRecord } from '@app/utils/agentRegistration/transformAgentRegistrationRecord' +import { useBreakpoint } from '@app/utils/BreakpointProvider' + +// Icon components defined outside of parent components +const UpRightArrowIcon = () => +const CopyIcon = () => + +export const AgentProfileButton = ({ + agentId, + registryAddress, + registryDisplayName, + explorerUrl, + displayValue, +}: AgentRegistrationRecord) => { + const breakpoints = useBreakpoint() + const [, copy] = useCopyToClipboard() + + const items: DropdownItem[] = [ + { + icon: CopyIcon, + label: 'Copy agent ID', + onClick: () => copy(agentId), + }, + { + icon: CopyIcon, + label: 'Copy registry address', + onClick: () => copy(registryAddress), + }, + ...(explorerUrl + ? [ + { + icon: UpRightArrowIcon, + label: 'View on explorer', + onClick: () => window.open(explorerUrl, '_blank', 'noopener,noreferrer'), + }, + ] + : []), + ] + + // Truncate display value for small screens + const truncatedDisplayValue = useMemo(() => { + if (breakpoints.sm) return displayValue + // For small screens, show a shorter version + return `id: ${agentId} | ${registryDisplayName}` + }, [breakpoints.sm, displayValue, agentId, registryDisplayName]) + + return ( + + + {truncatedDisplayValue} + + + ) +} diff --git a/src/components/pages/profile/ProfileDetails.tsx b/src/components/pages/profile/ProfileDetails.tsx index 82e3e8255..ad8633b16 100644 --- a/src/components/pages/profile/ProfileDetails.tsx +++ b/src/components/pages/profile/ProfileDetails.tsx @@ -9,6 +9,7 @@ import { Outlink } from '@app/components/Outlink' import coinsWithIcons from '@app/constants/coinsWithIcons.json' import { useProfileActions } from '@app/hooks/pages/profile/[name]/profile/useProfileActions/useProfileActions' import { useOwners } from '@app/hooks/useOwners' +import { AgentRegistrationRecord } from '@app/utils/agentRegistration/transformAgentRegistrationRecord' import { useBreakpoint } from '@app/utils/BreakpointProvider' import { ProfileAccountRecord, @@ -16,6 +17,7 @@ import { } from '@app/utils/records/categoriseProfileTextRecords' import { checkETH2LDFromName, formatExpiry } from '@app/utils/utils' +import { AgentProfileButton } from './AgentProfileButton' import { AddressProfileButton, OtherProfileButton, @@ -304,6 +306,7 @@ export const ProfileDetails = ({ otherRecords = [], addresses = [], verificationRecords = [], + agentRegistrations = [], expiryDate, pccExpired, owners, @@ -316,6 +319,7 @@ export const ProfileDetails = ({ otherRecords: ProfileOtherRecord[] addresses: Array> verificationRecords?: Array> + agentRegistrations?: AgentRegistrationRecord[] expiryDate: Date | undefined pccExpired: boolean owners: ReturnType @@ -351,6 +355,13 @@ export const ProfileDetails = ({ array={addresses} button={AddressProfileButton} /> + 0} + array={agentRegistrations} + button={AgentProfileButton} + name={name} + /> 0} diff --git a/src/components/pages/profile/[name]/tabs/ProfileTab.tsx b/src/components/pages/profile/[name]/tabs/ProfileTab.tsx index b842ebd03..c86bfec1c 100644 --- a/src/components/pages/profile/[name]/tabs/ProfileTab.tsx +++ b/src/components/pages/profile/[name]/tabs/ProfileTab.tsx @@ -161,6 +161,7 @@ const ProfileTab = ({ nameDetails, name }: Props) => { }))} accountRecords={categorisedRecord.accounts} otherRecords={categorisedRecord.other} + agentRegistrations={categorisedRecord.agentRegistrations} verificationRecords={getVerificationRecordItemProps({ showErrors: userHasOwnership, verifiedRecordsData: verifiedData, diff --git a/src/constants/knownAgentRegistries.ts b/src/constants/knownAgentRegistries.ts new file mode 100644 index 000000000..60be0f5d6 --- /dev/null +++ b/src/constants/knownAgentRegistries.ts @@ -0,0 +1,28 @@ +export interface KnownAgentRegistry { + name: string + chainId: number + address: string +} + +export const KNOWN_AGENT_REGISTRIES: KnownAgentRegistry[] = [ + { + name: '8004.eth', + chainId: 1, + address: '0x8004a169fb4a3325136eb29fa0ceb6d2e539a432', + }, +] + +/** + * Looks up a known registry name by chain ID and address. + * + * @param chainId - The EVM chain ID + * @param address - The registry contract address + * @returns The registry name (e.g., "8004.eth") or null if unknown + */ +export function getKnownRegistryName(chainId: number, address: string): string | null { + const normalizedAddress = address.toLowerCase() + const registry = KNOWN_AGENT_REGISTRIES.find( + (r) => r.chainId === chainId && r.address.toLowerCase() === normalizedAddress, + ) + return registry?.name ?? null +} diff --git a/src/utils/agentRegistration/decodeErc7930Address.test.ts b/src/utils/agentRegistration/decodeErc7930Address.test.ts new file mode 100644 index 000000000..f4628529d --- /dev/null +++ b/src/utils/agentRegistration/decodeErc7930Address.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest' + +import { decodeErc7930Address } from './decodeErc7930Address' + +describe('decodeErc7930Address', () => { + it('should decode a valid ERC-7930 address for Ethereum mainnet', () => { + // Version: 0001, ChainType: 0000 (EVM), ChainRefLength: 01, ChainRef: 01 (mainnet) + // AddressLength: 14 (20 bytes), Address: 8004a169fb4a3325136eb29fa0ceb6d2e539a432 + const hex = '0x00010000010114' + '8004a169fb4a3325136eb29fa0ceb6d2e539a432' + const result = decodeErc7930Address(hex) + + expect(result).toEqual({ + chainId: 1, + address: '0x8004a169fb4a3325136eb29fa0ceb6d2e539a432', + }) + }) + + it('should decode a valid ERC-7930 address for Base (chain ID 8453)', () => { + // Version: 0001, ChainType: 0000 (EVM), ChainRefLength: 02, ChainRef: 2105 (8453 in hex) + // AddressLength: 14 (20 bytes), Address: 1234567890abcdef1234567890abcdef12345678 + const hex = '0x0001000002210514' + '1234567890abcdef1234567890abcdef12345678' + const result = decodeErc7930Address(hex) + + expect(result).toEqual({ + chainId: 8453, + address: '0x1234567890abcdef1234567890abcdef12345678', + }) + }) + + it('should handle hex without 0x prefix', () => { + const hex = '00010000010114' + '8004a169fb4a3325136eb29fa0ceb6d2e539a432' + const result = decodeErc7930Address(hex) + + expect(result).toEqual({ + chainId: 1, + address: '0x8004a169fb4a3325136eb29fa0ceb6d2e539a432', + }) + }) + + it('should return null for invalid version', () => { + // Version: 0002 (invalid) + const hex = '0x00020000010114' + '8004a169fb4a3325136eb29fa0ceb6d2e539a432' + expect(decodeErc7930Address(hex)).toBeNull() + }) + + it('should return null for non-EVM chain type', () => { + // ChainType: 0001 (non-EVM) + const hex = '0x00010001010114' + '8004a169fb4a3325136eb29fa0ceb6d2e539a432' + expect(decodeErc7930Address(hex)).toBeNull() + }) + + it('should return null for invalid address length', () => { + // AddressLength: 15 (21 bytes, should be 20) + const hex = '0x00010000010115' + '8004a169fb4a3325136eb29fa0ceb6d2e539a432' + expect(decodeErc7930Address(hex)).toBeNull() + }) + + it('should return null for too short hex', () => { + expect(decodeErc7930Address('0x0001')).toBeNull() + expect(decodeErc7930Address('')).toBeNull() + }) + + it('should return null for zero chain reference length', () => { + const hex = '0x00010000000014' + '8004a169fb4a3325136eb29fa0ceb6d2e539a432' + expect(decodeErc7930Address(hex)).toBeNull() + }) + + it('should return null if address is truncated', () => { + // Only 10 bytes of address instead of 20 + const hex = '0x00010000010114' + '8004a169fb4a33251' + expect(decodeErc7930Address(hex)).toBeNull() + }) +}) diff --git a/src/utils/agentRegistration/decodeErc7930Address.ts b/src/utils/agentRegistration/decodeErc7930Address.ts new file mode 100644 index 000000000..1e2a93154 --- /dev/null +++ b/src/utils/agentRegistration/decodeErc7930Address.ts @@ -0,0 +1,62 @@ +/** + * Decodes an ERC-7930 binary address format. + * + * Format: + * - Version (2 bytes): 0x0001 + * - ChainType (2 bytes): 0x0000 for EVM + * - ChainReferenceLength (1 byte) + * - ChainReference (variable): Chain ID + * - AddressLength (1 byte): Should be 20 + * - Address (20 bytes) + * + * @param hex - The hex-encoded ERC-7930 address + * @returns Decoded chain ID and address, or null if invalid + */ +export function decodeErc7930Address( + hex: string, +): { chainId: number; address: `0x${string}` } | null { + // Remove 0x prefix if present + const cleanHex = hex.startsWith('0x') ? hex.slice(2) : hex + + // Minimum length check: 2 + 2 + 1 + 1 + 1 + 20 = 27 bytes = 54 hex chars + if (cleanHex.length < 54) return null + + let offset = 0 + + // Version (2 bytes) + const version = cleanHex.slice(offset, offset + 4) + offset += 4 + if (version !== '0001') return null + + // ChainType (2 bytes) + const chainType = cleanHex.slice(offset, offset + 4) + offset += 4 + if (chainType !== '0000') return null // Only EVM supported + + // ChainReferenceLength (1 byte) + const chainRefLength = parseInt(cleanHex.slice(offset, offset + 2), 16) + offset += 2 + if (chainRefLength === 0 || chainRefLength > 32) return null + + // ChainReference (variable) + const chainRefHex = cleanHex.slice(offset, offset + chainRefLength * 2) + offset += chainRefLength * 2 + if (chainRefHex.length !== chainRefLength * 2) return null + + const chainId = parseInt(chainRefHex, 16) + if (Number.isNaN(chainId)) return null + + // AddressLength (1 byte) + const addressLength = parseInt(cleanHex.slice(offset, offset + 2), 16) + offset += 2 + if (addressLength !== 20) return null + + // Address (20 bytes) + const addressHex = cleanHex.slice(offset, offset + 40) + if (addressHex.length !== 40) return null + + return { + chainId, + address: `0x${addressHex}` as `0x${string}`, + } +} diff --git a/src/utils/agentRegistration/getChainInfo.ts b/src/utils/agentRegistration/getChainInfo.ts new file mode 100644 index 000000000..830e241f5 --- /dev/null +++ b/src/utils/agentRegistration/getChainInfo.ts @@ -0,0 +1,49 @@ +import { extractChain } from 'viem' +import * as chains from 'viem/chains' + +export interface ChainInfo { + name: string + explorerUrl: string | null +} + +/** + * Gets chain information (name and block explorer URL) from a chain ID. + * Uses viem's chain registry for lookup. + * + * @param chainId - The EVM chain ID + * @returns Chain info with name and explorer URL + */ +export function getChainInfo(chainId: number): ChainInfo { + const chain = extractChain({ + chains: Object.values(chains), + id: chainId as (typeof chains.mainnet)['id'], + }) + + if (chain) { + return { + name: chain.name.toLowerCase(), + explorerUrl: chain.blockExplorers?.default?.url ?? null, + } + } + + // Fallback for unknown chains + return { + name: `chain:${chainId}`, + explorerUrl: null, + } +} + +/** + * Builds a block explorer URL for an address. + * + * @param explorerUrl - The base explorer URL (or null) + * @param address - The address to link to + * @returns Full URL or null if no explorer available + */ +export function buildAddressExplorerUrl( + explorerUrl: string | null, + address: string, +): string | null { + if (!explorerUrl) return null + return `${explorerUrl}/address/${address}` +} diff --git a/src/utils/agentRegistration/parseAgentRegistrationKey.test.ts b/src/utils/agentRegistration/parseAgentRegistrationKey.test.ts new file mode 100644 index 000000000..d74e9e803 --- /dev/null +++ b/src/utils/agentRegistration/parseAgentRegistrationKey.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' + +import { isAgentRegistrationKey, parseAgentRegistrationKey } from './parseAgentRegistrationKey' + +describe('parseAgentRegistrationKey', () => { + it('should parse a valid agent-registration key', () => { + const key = + 'agent-registration[0x00010000018004a169fb4a3325136eb29fa0ceb6d2e539a432148004a169fb4a3325136eb29fa0ceb6d2e539a432][19151]' + const result = parseAgentRegistrationKey(key) + + expect(result).toEqual({ + registryHex: + '0x00010000018004a169fb4a3325136eb29fa0ceb6d2e539a432148004a169fb4a3325136eb29fa0ceb6d2e539a432', + agentId: '19151', + }) + }) + + it('should parse a key with numeric agent ID', () => { + const key = 'agent-registration[0x0001000001018004a169fb4a3325136eb29fa0ceb6d2e539a432][167]' + const result = parseAgentRegistrationKey(key) + + expect(result).toEqual({ + registryHex: '0x0001000001018004a169fb4a3325136eb29fa0ceb6d2e539a432', + agentId: '167', + }) + }) + + it('should return null for non-agent-registration keys', () => { + expect(parseAgentRegistrationKey('com.twitter')).toBeNull() + expect(parseAgentRegistrationKey('avatar')).toBeNull() + expect(parseAgentRegistrationKey('')).toBeNull() + }) + + it('should return null for malformed keys', () => { + // Missing closing bracket + expect(parseAgentRegistrationKey('agent-registration[0x1234')).toBeNull() + + // Missing second section + expect(parseAgentRegistrationKey('agent-registration[0x1234]')).toBeNull() + + // Missing hex prefix + expect(parseAgentRegistrationKey('agent-registration[1234][5678]')).toBeNull() + + // Empty agent ID + expect(parseAgentRegistrationKey('agent-registration[0x1234][]')).toBeNull() + }) + + it('should lowercase the registry hex', () => { + const key = 'agent-registration[0xABCDEF123456][789]' + const result = parseAgentRegistrationKey(key) + + expect(result?.registryHex).toBe('0xabcdef123456') + }) +}) + +describe('isAgentRegistrationKey', () => { + it('should return true for agent-registration keys', () => { + expect(isAgentRegistrationKey('agent-registration[0x1234][5678]')).toBe(true) + expect(isAgentRegistrationKey('agent-registration[anything')).toBe(true) + }) + + it('should return false for non-agent-registration keys', () => { + expect(isAgentRegistrationKey('com.twitter')).toBe(false) + expect(isAgentRegistrationKey('avatar')).toBe(false) + expect(isAgentRegistrationKey('')).toBe(false) + expect(isAgentRegistrationKey('agent-registrations[0x1234]')).toBe(false) + }) +}) diff --git a/src/utils/agentRegistration/parseAgentRegistrationKey.ts b/src/utils/agentRegistration/parseAgentRegistrationKey.ts new file mode 100644 index 000000000..6b69191e8 --- /dev/null +++ b/src/utils/agentRegistration/parseAgentRegistrationKey.ts @@ -0,0 +1,41 @@ +/** + * Parses an ENSIP-25 agent-registration text record key. + * + * Format: agent-registration[][] + * + * @param key - The text record key to parse + * @returns Parsed registry hex and agent ID, or null if invalid + */ +export function parseAgentRegistrationKey( + key: string, +): { registryHex: string; agentId: string } | null { + const prefix = 'agent-registration[' + + if (!key.startsWith(prefix)) return null + + // Find the closing bracket of the first section + const firstCloseIndex = key.indexOf('][') + if (firstCloseIndex === -1) return null + + // Extract registry (between first [ and ][) + const registryHex = key.slice(prefix.length, firstCloseIndex) + + // Extract agentId (between ][ and final ]) + const agentIdStart = firstCloseIndex + 2 + const agentIdEnd = key.lastIndexOf(']') + if (agentIdEnd <= agentIdStart) return null + + const agentId = key.slice(agentIdStart, agentIdEnd) + + // Validate registry looks like hex + if (!registryHex.startsWith('0x')) return null + + return { registryHex: registryHex.toLowerCase(), agentId } +} + +/** + * Checks if a text record key is an agent-registration key. + */ +export function isAgentRegistrationKey(key: string): boolean { + return key.startsWith('agent-registration[') +} diff --git a/src/utils/agentRegistration/transformAgentRegistrationRecord.ts b/src/utils/agentRegistration/transformAgentRegistrationRecord.ts new file mode 100644 index 000000000..91a30a882 --- /dev/null +++ b/src/utils/agentRegistration/transformAgentRegistrationRecord.ts @@ -0,0 +1,58 @@ +import type { DecodedText } from '@ensdomains/ensjs/dist/types' + +import { getKnownRegistryName } from '@app/constants/knownAgentRegistries' +import { shortenAddress } from '@app/utils/utils' + +import { decodeErc7930Address } from './decodeErc7930Address' +import { buildAddressExplorerUrl, getChainInfo } from './getChainInfo' +import { parseAgentRegistrationKey } from './parseAgentRegistrationKey' + +export interface AgentRegistrationRecord { + key: string + value: string // Required by ProfileSection component + agentId: string + chainId: number + chainName: string + registryAddress: `0x${string}` + registryDisplayName: string // ENS name or shortened address + explorerUrl: string | null // Block explorer URL from viem + displayValue: string // "id: 167 | registry: 8004.eth@ethereum" + iconKey: 'agent' +} + +/** + * Transforms a raw text record into an AgentRegistrationRecord if it's a valid + * ENSIP-25 agent-registration record. + * + * @param record - The decoded text record from ENS + * @returns Transformed agent registration record, or null if invalid + */ +export function transformAgentRegistrationRecord( + record: DecodedText, +): AgentRegistrationRecord | null { + const parsed = parseAgentRegistrationKey(record.key) + if (!parsed) return null + + const decoded = decodeErc7930Address(parsed.registryHex) + if (!decoded) return null + + const chainInfo = getChainInfo(decoded.chainId) + const knownName = getKnownRegistryName(decoded.chainId, decoded.address) + const registryDisplayName = knownName ?? shortenAddress(decoded.address, 13, 6, 4) + const explorerUrl = buildAddressExplorerUrl(chainInfo.explorerUrl, decoded.address) + + const displayValue = `id: ${parsed.agentId} | registry: ${registryDisplayName}@${chainInfo.name}` + + return { + key: record.key, + value: displayValue, + agentId: parsed.agentId, + chainId: decoded.chainId, + chainName: chainInfo.name, + registryAddress: decoded.address, + registryDisplayName, + explorerUrl, + displayValue, + iconKey: 'agent', + } +} diff --git a/src/utils/records/categoriseProfileTextRecords.test.ts b/src/utils/records/categoriseProfileTextRecords.test.ts index 26a1ff170..fbaeca907 100644 --- a/src/utils/records/categoriseProfileTextRecords.test.ts +++ b/src/utils/records/categoriseProfileTextRecords.test.ts @@ -38,6 +38,7 @@ describe('categoriseProfileTextRecords', () => { iconKey: 'contenthash', }, ], + agentRegistrations: [], }) }) @@ -68,6 +69,36 @@ describe('categoriseProfileTextRecords', () => { }, ], other: [{ key: 'other', value: 'value', type: 'text', iconKey: 'other' }], + agentRegistrations: [], }) }) + + it('should categorise agent-registration records correctly', () => { + // ERC-7930 encoded address for mainnet (chain ID 1) with address 0x8004a169fb4a3325136eb29fa0ceb6d2e539a432 + const erc7930Hex = '0x00010000010114' + '8004a169fb4a3325136eb29fa0ceb6d2e539a432' + const result = categoriseAndTransformTextRecords({ + texts: [{ key: `agent-registration[${erc7930Hex}][19151]`, value: '1' }], + contentHash: undefined, + }) + + expect(result.agentRegistrations).toHaveLength(1) + expect(result.agentRegistrations[0]).toMatchObject({ + agentId: '19151', + chainId: 1, + registryAddress: '0x8004a169fb4a3325136eb29fa0ceb6d2e539a432', + iconKey: 'agent', + }) + expect(result.other).toHaveLength(0) + }) + + it('should fall back to other for invalid agent-registration records', () => { + const result = categoriseAndTransformTextRecords({ + texts: [{ key: 'agent-registration[invalid][123]', value: '1' }], + contentHash: undefined, + }) + + expect(result.agentRegistrations).toHaveLength(0) + expect(result.other).toHaveLength(1) + expect(result.other[0].key).toBe('agent-registration[invalid][123]') + }) }) diff --git a/src/utils/records/categoriseProfileTextRecords.ts b/src/utils/records/categoriseProfileTextRecords.ts index 0b2f45b96..b4e77d47b 100644 --- a/src/utils/records/categoriseProfileTextRecords.ts +++ b/src/utils/records/categoriseProfileTextRecords.ts @@ -10,6 +10,11 @@ import { } from '@app/constants/supportedSocialRecordKeys' import { VERIFICATION_RECORD_KEY } from '@app/constants/verification' import { VerificationProtocol } from '@app/transaction-flow/input/VerifyProfile/VerifyProfile-flow' +import { isAgentRegistrationKey } from '@app/utils/agentRegistration/parseAgentRegistrationKey' +import { + AgentRegistrationRecord, + transformAgentRegistrationRecord, +} from '@app/utils/agentRegistration/transformAgentRegistrationRecord' import { contentHashToString } from '../contenthash' import { @@ -41,8 +46,18 @@ export const categoriseAndTransformTextRecords = ({ general: DecodedText[] accounts: ProfileAccountRecord[] other: ProfileOtherRecord[] + agentRegistrations: AgentRegistrationRecord[] }>( (acc, record) => { + // Check for agent-registration records first + if (isAgentRegistrationKey(record.key)) { + const agentRecord = transformAgentRegistrationRecord(record) + if (agentRecord) { + return { ...acc, agentRegistrations: [...acc.agentRegistrations, agentRecord] } + } + // If parsing fails, fall through to treat as "other" record + } + const normalisedRecord = normaliseProfileAccountsRecord(record) if ( supportedSocialRecordKeys.includes( @@ -77,6 +92,7 @@ export const categoriseAndTransformTextRecords = ({ general: [], accounts: [], other: [], + agentRegistrations: [], }, )