Skip to content

Commit 62184df

Browse files
committed
Initial support
1 parent 8062421 commit 62184df

14 files changed

+507
-0
lines changed

public/locales/en/profile.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
"yourProfile": "Your profile",
99
"accounts": "Accounts",
1010
"addresses": "Addresses",
11+
"agents": "AI Agents",
1112
"otherRecords": "Other Records",
1213
"verifications": "Verifications",
1314
"editProfile": "Edit Profile",
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { useMemo } from 'react'
2+
import { useCopyToClipboard } from 'react-use'
3+
4+
import { CopySVG, Dropdown, RecordItem, UpRightArrowSVG, VerticalDotsSVG } from '@ensdomains/thorin'
5+
import { DropdownItem } from '@ensdomains/thorin/dist/types/components/molecules/Dropdown/Dropdown'
6+
7+
import { AgentRegistrationRecord } from '@app/utils/agentRegistration/transformAgentRegistrationRecord'
8+
import { useBreakpoint } from '@app/utils/BreakpointProvider'
9+
10+
// Icon components defined outside of parent components
11+
const UpRightArrowIcon = () => <UpRightArrowSVG height={16} width={16} />
12+
const CopyIcon = () => <CopySVG height={16} width={16} />
13+
14+
export const AgentProfileButton = ({
15+
agentId,
16+
registryAddress,
17+
registryDisplayName,
18+
explorerUrl,
19+
displayValue,
20+
}: AgentRegistrationRecord) => {
21+
const breakpoints = useBreakpoint()
22+
const [, copy] = useCopyToClipboard()
23+
24+
const items: DropdownItem[] = [
25+
{
26+
icon: CopyIcon,
27+
label: 'Copy agent ID',
28+
onClick: () => copy(agentId),
29+
},
30+
{
31+
icon: CopyIcon,
32+
label: 'Copy registry address',
33+
onClick: () => copy(registryAddress),
34+
},
35+
...(explorerUrl
36+
? [
37+
{
38+
icon: UpRightArrowIcon,
39+
label: 'View on explorer',
40+
onClick: () => window.open(explorerUrl, '_blank', 'noopener,noreferrer'),
41+
},
42+
]
43+
: []),
44+
]
45+
46+
// Truncate display value for small screens
47+
const truncatedDisplayValue = useMemo(() => {
48+
if (breakpoints.sm) return displayValue
49+
// For small screens, show a shorter version
50+
return `id: ${agentId} | ${registryDisplayName}`
51+
}, [breakpoints.sm, displayValue, agentId, registryDisplayName])
52+
53+
return (
54+
<Dropdown width={200} items={items} direction="up">
55+
<RecordItem
56+
data-testid={`agent-profile-button-${agentId}`}
57+
postfixIcon={VerticalDotsSVG}
58+
value={displayValue}
59+
size={breakpoints.sm ? 'large' : 'small'}
60+
inline
61+
>
62+
{truncatedDisplayValue}
63+
</RecordItem>
64+
</Dropdown>
65+
)
66+
}

src/components/pages/profile/ProfileDetails.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@ import { Outlink } from '@app/components/Outlink'
99
import coinsWithIcons from '@app/constants/coinsWithIcons.json'
1010
import { useProfileActions } from '@app/hooks/pages/profile/[name]/profile/useProfileActions/useProfileActions'
1111
import { useOwners } from '@app/hooks/useOwners'
12+
import { AgentRegistrationRecord } from '@app/utils/agentRegistration/transformAgentRegistrationRecord'
1213
import { useBreakpoint } from '@app/utils/BreakpointProvider'
1314
import {
1415
ProfileAccountRecord,
1516
ProfileOtherRecord,
1617
} from '@app/utils/records/categoriseProfileTextRecords'
1718
import { checkETH2LDFromName, formatExpiry } from '@app/utils/utils'
1819

20+
import { AgentProfileButton } from './AgentProfileButton'
1921
import {
2022
AddressProfileButton,
2123
OtherProfileButton,
@@ -304,6 +306,7 @@ export const ProfileDetails = ({
304306
otherRecords = [],
305307
addresses = [],
306308
verificationRecords = [],
309+
agentRegistrations = [],
307310
expiryDate,
308311
pccExpired,
309312
owners,
@@ -316,6 +319,7 @@ export const ProfileDetails = ({
316319
otherRecords: ProfileOtherRecord[]
317320
addresses: Array<Record<'key' | 'value', string>>
318321
verificationRecords?: Array<Record<'key' | 'value', string>>
322+
agentRegistrations?: AgentRegistrationRecord[]
319323
expiryDate: Date | undefined
320324
pccExpired: boolean
321325
owners: ReturnType<typeof useOwners>
@@ -351,6 +355,13 @@ export const ProfileDetails = ({
351355
array={addresses}
352356
button={AddressProfileButton}
353357
/>
358+
<ProfileSection
359+
label="agents"
360+
condition={agentRegistrations.length > 0}
361+
array={agentRegistrations}
362+
button={AgentProfileButton}
363+
name={name}
364+
/>
354365
<ProfileSection
355366
label="otherRecords"
356367
condition={otherRecords && otherRecords.length > 0}

src/components/pages/profile/[name]/tabs/ProfileTab.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ const ProfileTab = ({ nameDetails, name }: Props) => {
161161
}))}
162162
accountRecords={categorisedRecord.accounts}
163163
otherRecords={categorisedRecord.other}
164+
agentRegistrations={categorisedRecord.agentRegistrations}
164165
verificationRecords={getVerificationRecordItemProps({
165166
showErrors: userHasOwnership,
166167
verifiedRecordsData: verifiedData,
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
export interface KnownAgentRegistry {
2+
name: string
3+
chainId: number
4+
address: string
5+
}
6+
7+
export const KNOWN_AGENT_REGISTRIES: KnownAgentRegistry[] = [
8+
{
9+
name: '8004.eth',
10+
chainId: 1,
11+
address: '0x8004a169fb4a3325136eb29fa0ceb6d2e539a432',
12+
},
13+
]
14+
15+
/**
16+
* Looks up a known registry name by chain ID and address.
17+
*
18+
* @param chainId - The EVM chain ID
19+
* @param address - The registry contract address
20+
* @returns The registry name (e.g., "8004.eth") or null if unknown
21+
*/
22+
export function getKnownRegistryName(chainId: number, address: string): string | null {
23+
const normalizedAddress = address.toLowerCase()
24+
const registry = KNOWN_AGENT_REGISTRIES.find(
25+
(r) => r.chainId === chainId && r.address.toLowerCase() === normalizedAddress,
26+
)
27+
return registry?.name ?? null
28+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import { decodeErc7930Address } from './decodeErc7930Address'
4+
5+
describe('decodeErc7930Address', () => {
6+
it('should decode a valid ERC-7930 address for Ethereum mainnet', () => {
7+
// Version: 0001, ChainType: 0000 (EVM), ChainRefLength: 01, ChainRef: 01 (mainnet)
8+
// AddressLength: 14 (20 bytes), Address: 8004a169fb4a3325136eb29fa0ceb6d2e539a432
9+
const hex = '0x00010000010114' + '8004a169fb4a3325136eb29fa0ceb6d2e539a432'
10+
const result = decodeErc7930Address(hex)
11+
12+
expect(result).toEqual({
13+
chainId: 1,
14+
address: '0x8004a169fb4a3325136eb29fa0ceb6d2e539a432',
15+
})
16+
})
17+
18+
it('should decode a valid ERC-7930 address for Base (chain ID 8453)', () => {
19+
// Version: 0001, ChainType: 0000 (EVM), ChainRefLength: 02, ChainRef: 2105 (8453 in hex)
20+
// AddressLength: 14 (20 bytes), Address: 1234567890abcdef1234567890abcdef12345678
21+
const hex = '0x0001000002210514' + '1234567890abcdef1234567890abcdef12345678'
22+
const result = decodeErc7930Address(hex)
23+
24+
expect(result).toEqual({
25+
chainId: 8453,
26+
address: '0x1234567890abcdef1234567890abcdef12345678',
27+
})
28+
})
29+
30+
it('should handle hex without 0x prefix', () => {
31+
const hex = '00010000010114' + '8004a169fb4a3325136eb29fa0ceb6d2e539a432'
32+
const result = decodeErc7930Address(hex)
33+
34+
expect(result).toEqual({
35+
chainId: 1,
36+
address: '0x8004a169fb4a3325136eb29fa0ceb6d2e539a432',
37+
})
38+
})
39+
40+
it('should return null for invalid version', () => {
41+
// Version: 0002 (invalid)
42+
const hex = '0x00020000010114' + '8004a169fb4a3325136eb29fa0ceb6d2e539a432'
43+
expect(decodeErc7930Address(hex)).toBeNull()
44+
})
45+
46+
it('should return null for non-EVM chain type', () => {
47+
// ChainType: 0001 (non-EVM)
48+
const hex = '0x00010001010114' + '8004a169fb4a3325136eb29fa0ceb6d2e539a432'
49+
expect(decodeErc7930Address(hex)).toBeNull()
50+
})
51+
52+
it('should return null for invalid address length', () => {
53+
// AddressLength: 15 (21 bytes, should be 20)
54+
const hex = '0x00010000010115' + '8004a169fb4a3325136eb29fa0ceb6d2e539a432'
55+
expect(decodeErc7930Address(hex)).toBeNull()
56+
})
57+
58+
it('should return null for too short hex', () => {
59+
expect(decodeErc7930Address('0x0001')).toBeNull()
60+
expect(decodeErc7930Address('')).toBeNull()
61+
})
62+
63+
it('should return null for zero chain reference length', () => {
64+
const hex = '0x00010000000014' + '8004a169fb4a3325136eb29fa0ceb6d2e539a432'
65+
expect(decodeErc7930Address(hex)).toBeNull()
66+
})
67+
68+
it('should return null if address is truncated', () => {
69+
// Only 10 bytes of address instead of 20
70+
const hex = '0x00010000010114' + '8004a169fb4a33251'
71+
expect(decodeErc7930Address(hex)).toBeNull()
72+
})
73+
})
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/**
2+
* Decodes an ERC-7930 binary address format.
3+
*
4+
* Format:
5+
* - Version (2 bytes): 0x0001
6+
* - ChainType (2 bytes): 0x0000 for EVM
7+
* - ChainReferenceLength (1 byte)
8+
* - ChainReference (variable): Chain ID
9+
* - AddressLength (1 byte): Should be 20
10+
* - Address (20 bytes)
11+
*
12+
* @param hex - The hex-encoded ERC-7930 address
13+
* @returns Decoded chain ID and address, or null if invalid
14+
*/
15+
export function decodeErc7930Address(
16+
hex: string,
17+
): { chainId: number; address: `0x${string}` } | null {
18+
// Remove 0x prefix if present
19+
const cleanHex = hex.startsWith('0x') ? hex.slice(2) : hex
20+
21+
// Minimum length check: 2 + 2 + 1 + 1 + 1 + 20 = 27 bytes = 54 hex chars
22+
if (cleanHex.length < 54) return null
23+
24+
let offset = 0
25+
26+
// Version (2 bytes)
27+
const version = cleanHex.slice(offset, offset + 4)
28+
offset += 4
29+
if (version !== '0001') return null
30+
31+
// ChainType (2 bytes)
32+
const chainType = cleanHex.slice(offset, offset + 4)
33+
offset += 4
34+
if (chainType !== '0000') return null // Only EVM supported
35+
36+
// ChainReferenceLength (1 byte)
37+
const chainRefLength = parseInt(cleanHex.slice(offset, offset + 2), 16)
38+
offset += 2
39+
if (chainRefLength === 0 || chainRefLength > 32) return null
40+
41+
// ChainReference (variable)
42+
const chainRefHex = cleanHex.slice(offset, offset + chainRefLength * 2)
43+
offset += chainRefLength * 2
44+
if (chainRefHex.length !== chainRefLength * 2) return null
45+
46+
const chainId = parseInt(chainRefHex, 16)
47+
if (Number.isNaN(chainId)) return null
48+
49+
// AddressLength (1 byte)
50+
const addressLength = parseInt(cleanHex.slice(offset, offset + 2), 16)
51+
offset += 2
52+
if (addressLength !== 20) return null
53+
54+
// Address (20 bytes)
55+
const addressHex = cleanHex.slice(offset, offset + 40)
56+
if (addressHex.length !== 40) return null
57+
58+
return {
59+
chainId,
60+
address: `0x${addressHex}` as `0x${string}`,
61+
}
62+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { extractChain } from 'viem'
2+
import * as chains from 'viem/chains'
3+
4+
export interface ChainInfo {
5+
name: string
6+
explorerUrl: string | null
7+
}
8+
9+
/**
10+
* Gets chain information (name and block explorer URL) from a chain ID.
11+
* Uses viem's chain registry for lookup.
12+
*
13+
* @param chainId - The EVM chain ID
14+
* @returns Chain info with name and explorer URL
15+
*/
16+
export function getChainInfo(chainId: number): ChainInfo {
17+
const chain = extractChain({
18+
chains: Object.values(chains),
19+
id: chainId as (typeof chains.mainnet)['id'],
20+
})
21+
22+
if (chain) {
23+
return {
24+
name: chain.name.toLowerCase(),
25+
explorerUrl: chain.blockExplorers?.default?.url ?? null,
26+
}
27+
}
28+
29+
// Fallback for unknown chains
30+
return {
31+
name: `chain:${chainId}`,
32+
explorerUrl: null,
33+
}
34+
}
35+
36+
/**
37+
* Builds a block explorer URL for an address.
38+
*
39+
* @param explorerUrl - The base explorer URL (or null)
40+
* @param address - The address to link to
41+
* @returns Full URL or null if no explorer available
42+
*/
43+
export function buildAddressExplorerUrl(
44+
explorerUrl: string | null,
45+
address: string,
46+
): string | null {
47+
if (!explorerUrl) return null
48+
return `${explorerUrl}/address/${address}`
49+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export { decodeErc7930Address } from './decodeErc7930Address'
2+
export { buildAddressExplorerUrl, getChainInfo } from './getChainInfo'
3+
export type { ChainInfo } from './getChainInfo'
4+
export { isAgentRegistrationKey, parseAgentRegistrationKey } from './parseAgentRegistrationKey'
5+
export { transformAgentRegistrationRecord } from './transformAgentRegistrationRecord'
6+
export type { AgentRegistrationRecord } from './transformAgentRegistrationRecord'

0 commit comments

Comments
 (0)