Skip to content
Open
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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions public/locales/en/profile.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"yourProfile": "Your profile",
"accounts": "Accounts",
"addresses": "Addresses",
"agents": "AI Agents",
"otherRecords": "Other Records",
"verifications": "Verifications",
"editProfile": "Edit Profile",
Expand Down
66 changes: 66 additions & 0 deletions src/components/pages/profile/AgentProfileButton.tsx
Original file line number Diff line number Diff line change
@@ -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 = () => <UpRightArrowSVG height={16} width={16} />
const CopyIcon = () => <CopySVG height={16} width={16} />

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 (
<Dropdown width={200} items={items} direction="up">
<RecordItem
data-testid={`agent-profile-button-${agentId}`}
postfixIcon={VerticalDotsSVG}
value={displayValue}
size={breakpoints.sm ? 'large' : 'small'}
inline
>
{truncatedDisplayValue}
</RecordItem>
</Dropdown>
)
}
11 changes: 11 additions & 0 deletions src/components/pages/profile/ProfileDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ 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,
ProfileOtherRecord,
} from '@app/utils/records/categoriseProfileTextRecords'
import { checkETH2LDFromName, formatExpiry } from '@app/utils/utils'

import { AgentProfileButton } from './AgentProfileButton'
import {
AddressProfileButton,
OtherProfileButton,
Expand Down Expand Up @@ -304,6 +306,7 @@ export const ProfileDetails = ({
otherRecords = [],
addresses = [],
verificationRecords = [],
agentRegistrations = [],
expiryDate,
pccExpired,
owners,
Expand All @@ -316,6 +319,7 @@ export const ProfileDetails = ({
otherRecords: ProfileOtherRecord[]
addresses: Array<Record<'key' | 'value', string>>
verificationRecords?: Array<Record<'key' | 'value', string>>
agentRegistrations?: AgentRegistrationRecord[]
expiryDate: Date | undefined
pccExpired: boolean
owners: ReturnType<typeof useOwners>
Expand Down Expand Up @@ -351,6 +355,13 @@ export const ProfileDetails = ({
array={addresses}
button={AddressProfileButton}
/>
<ProfileSection
label="agents"
condition={agentRegistrations.length > 0}
array={agentRegistrations}
button={AgentProfileButton}
name={name}
/>
<ProfileSection
label="otherRecords"
condition={otherRecords && otherRecords.length > 0}
Expand Down
1 change: 1 addition & 0 deletions src/components/pages/profile/[name]/tabs/ProfileTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ const ProfileTab = ({ nameDetails, name }: Props) => {
}))}
accountRecords={categorisedRecord.accounts}
otherRecords={categorisedRecord.other}
agentRegistrations={categorisedRecord.agentRegistrations}
verificationRecords={getVerificationRecordItemProps({
showErrors: userHasOwnership,
verifiedRecordsData: verifiedData,
Expand Down
28 changes: 28 additions & 0 deletions src/constants/knownAgentRegistries.ts
Original file line number Diff line number Diff line change
@@ -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
}
73 changes: 73 additions & 0 deletions src/utils/agentRegistration/decodeErc7930Address.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
62 changes: 62 additions & 0 deletions src/utils/agentRegistration/decodeErc7930Address.ts
Original file line number Diff line number Diff line change
@@ -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)

Check warning on line 37 in src/utils/agentRegistration/decodeErc7930Address.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `Number.parseInt` over `parseInt`.

See more on https://sonarcloud.io/project/issues?id=ensdomains_ens-app-v3&issues=AZ028dPNo5CI2C-YFZhu&open=AZ028dPNo5CI2C-YFZhu&pullRequest=1121
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)

Check warning on line 46 in src/utils/agentRegistration/decodeErc7930Address.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `Number.parseInt` over `parseInt`.

See more on https://sonarcloud.io/project/issues?id=ensdomains_ens-app-v3&issues=AZ028dPNo5CI2C-YFZhv&open=AZ028dPNo5CI2C-YFZhv&pullRequest=1121
if (Number.isNaN(chainId)) return null

// AddressLength (1 byte)
const addressLength = parseInt(cleanHex.slice(offset, offset + 2), 16)

Check warning on line 50 in src/utils/agentRegistration/decodeErc7930Address.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `Number.parseInt` over `parseInt`.

See more on https://sonarcloud.io/project/issues?id=ensdomains_ens-app-v3&issues=AZ028dPNo5CI2C-YFZhw&open=AZ028dPNo5CI2C-YFZhw&pullRequest=1121
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}`,

Check warning on line 60 in src/utils/agentRegistration/decodeErc7930Address.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This assertion is unnecessary since it does not change the type of the expression.

See more on https://sonarcloud.io/project/issues?id=ensdomains_ens-app-v3&issues=AZ028dPNo5CI2C-YFZhx&open=AZ028dPNo5CI2C-YFZhx&pullRequest=1121
}
}
49 changes: 49 additions & 0 deletions src/utils/agentRegistration/getChainInfo.ts
Original file line number Diff line number Diff line change
@@ -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}`
}
Loading
Loading