Skip to content
Merged
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
10 changes: 9 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@x402r/erc8004",
"version": "0.1.0-alpha.0",
"version": "0.1.0-alpha.1",
"description": "Lightweight TypeScript SDK for ERC-8004 (Trustless Agents) — Identity and Reputation registries",
"type": "module",
"license": "Apache-2.0",
Expand Down Expand Up @@ -34,6 +34,10 @@
"types": "./dist/reputation/index.d.ts",
"default": "./dist/reputation/index.js"
},
"./registration": {
"types": "./dist/registration/index.d.ts",
"default": "./dist/registration/index.js"
},
"./abis": {
"types": "./dist/abis/index.d.ts",
"default": "./dist/abis/index.js"
Expand Down Expand Up @@ -83,6 +87,10 @@
"path": "dist/reputation/index.js",
"limit": "5 kB"
},
{
"path": "dist/registration/index.js",
"limit": "5 kB"
},
{
"path": "dist/abis/index.js",
"limit": "50 kB"
Expand Down
17 changes: 17 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,23 @@ export {
unsetAgentWallet,
verifyAgentId,
} from './identity/index.js'
export type {
AgentRegistrationFile,
CreateRegistrationFileParameters,
RegistrationBinding,
ResolvedServiceEndpoint,
ResolveServiceEndpointParameters,
ServiceEntry,
} from './registration/index.js'
export {
createRegistrationFile,
fetchRegistrationFile,
findService,
findServices,
parseRegistrationFile,
REGISTRATION_TYPE,
resolveServiceEndpoint,
} from './registration/index.js'
export type {
AppendResponseParameters,
Feedback,
Expand Down
14 changes: 14 additions & 0 deletions src/registration/build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type {
AgentRegistrationFile,
CreateRegistrationFileParameters,
} from './types.js'
import { REGISTRATION_TYPE } from './types.js'

/**
* Build a valid Agent Registration File with the spec `type` set automatically.
*/
export function createRegistrationFile(
parameters: CreateRegistrationFileParameters,
): AgentRegistrationFile {
return { type: REGISTRATION_TYPE, ...parameters } as AgentRegistrationFile
}
41 changes: 41 additions & 0 deletions src/registration/fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { parseRegistrationFile } from './parse.js'
import type { AgentRegistrationFile } from './types.js'

/**
* Fetch and validate an Agent Registration File from an HTTPS URL.
*
* Uses `globalThis.fetch` — no external HTTP dependencies required.
*
* @throws on non-HTTPS URL, non-200 response, non-JSON body, or invalid schema
*/
export async function fetchRegistrationFile(
uri: string,
): Promise<AgentRegistrationFile> {
if (!uri.startsWith('https://')) {
throw new Error(`Only HTTPS URIs are supported, got: ${uri.slice(0, 40)}`)
}

const response = await globalThis.fetch(uri, {
signal: AbortSignal.timeout(10_000),
})

if (!response.ok) {
throw new Error(
`Failed to fetch registration file: HTTP ${response.status}`,
)
}

const text = await response.text()
if (text.length > 1_048_576) {
throw new Error('Registration file exceeds 1 MB size limit')
}

let json: unknown
try {
json = JSON.parse(text)
} catch {
throw new Error('Response is not valid JSON')
}

return parseRegistrationFile(json)
}
14 changes: 14 additions & 0 deletions src/registration/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export { createRegistrationFile } from './build.js'
export { fetchRegistrationFile } from './fetch.js'
export { parseRegistrationFile } from './parse.js'
export { resolveServiceEndpoint } from './resolve.js'
export { findService, findServices } from './services.js'
export {
type AgentRegistrationFile,
type CreateRegistrationFileParameters,
REGISTRATION_TYPE,
type RegistrationBinding,
type ResolvedServiceEndpoint,
type ResolveServiceEndpointParameters,
type ServiceEntry,
} from './types.js'
107 changes: 107 additions & 0 deletions src/registration/parse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import type { AgentRegistrationFile } from './types.js'
import { REGISTRATION_TYPE } from './types.js'

/**
* Parse and validate a JSON value as an ERC-8004 Agent Registration File.
*
* Validates required fields (`type`, `name`, `description`, `image`, `services`)
* and ensures each service entry has at least `name` and `endpoint`.
*
* @throws on missing or invalid fields
*/
export function parseRegistrationFile(json: unknown): AgentRegistrationFile {
if (typeof json !== 'object' || json === null || Array.isArray(json)) {
throw new Error('Agent Registration File must be a JSON object')
}

let obj = json as Record<string, unknown>

if (obj.type !== REGISTRATION_TYPE) {
throw new Error(
`Invalid registration type: expected "${REGISTRATION_TYPE}", got ${JSON.stringify(obj.type)}`,
)
}

for (const field of ['name', 'description', 'image'] as const) {
if (typeof obj[field] !== 'string') {
throw new Error(`"${field}" must be a string, got ${typeof obj[field]}`)
}
}

if (!Array.isArray(obj.services)) {
throw new Error('"services" must be an array')
}

if (obj.services.length === 0) {
throw new Error('"services" must contain at least one entry')
}

for (let i = 0; i < obj.services.length; i++) {
validateServiceEntry(obj.services[i], i)
}

if (obj.active !== undefined && typeof obj.active !== 'boolean') {
throw new Error('"active" must be a boolean when present')
}

if (obj.registrations !== undefined) {
if (!Array.isArray(obj.registrations)) {
throw new Error('"registrations" must be an array when present')
}
for (let i = 0; i < obj.registrations.length; i++) {
validateRegistrationBinding(obj.registrations[i], i)
}
}

// Coerce registrations[].agentId to bigint without mutating the input
if (obj.registrations) {
obj = {
...obj,
registrations: (obj.registrations as Array<Record<string, unknown>>).map(
(b) => ({
...b,
agentId: BigInt(b.agentId as string | number | bigint),
}),
),
}
}

return obj as unknown as AgentRegistrationFile
}

function validateRegistrationBinding(entry: unknown, index: number): void {
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
throw new Error(`registrations[${index}] must be an object`)
}
const binding = entry as Record<string, unknown>
if (
typeof binding.agentId !== 'bigint' &&
typeof binding.agentId !== 'number' &&
typeof binding.agentId !== 'string'
) {
throw new Error(
`registrations[${index}].agentId must be a string, number, or bigint`,
)
}
if (typeof binding.agentId === 'string' && !/^\d+$/.test(binding.agentId)) {
throw new Error(
`registrations[${index}].agentId string must be a non-negative integer, got "${binding.agentId}"`,
)
}
if (typeof binding.agentRegistry !== 'string') {
throw new Error(`registrations[${index}].agentRegistry must be a string`)
}
}

function validateServiceEntry(entry: unknown, index: number): void {
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
throw new Error(`services[${index}] must be an object`)
}
const svc = entry as Record<string, unknown>
if (typeof svc.name !== 'string') {
throw new Error(`services[${index}].name must be a string`)
}
if (typeof svc.endpoint !== 'string') {
throw new Error(`services[${index}].endpoint must be a string`)
}
}
53 changes: 53 additions & 0 deletions src/registration/resolve.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { PublicClient } from 'viem'
import { resolveAgent } from '../identity/index.js'
import { fetchRegistrationFile } from './fetch.js'
import { findService } from './services.js'
import type {
AgentRegistrationFile,
ResolvedServiceEndpoint,
ResolveServiceEndpointParameters,
} from './types.js'

/**
* Resolve a service endpoint for an on-chain agent in one call.
*
* Pipeline: `resolveAgent(agentId)` → `fetchRegistrationFile(agentURI)`
* → `findService(file, serviceName)` → return endpoint + metadata.
*
* @throws if the agent doesn't exist, the URI fetch fails, or the service is not found
*/
export async function resolveServiceEndpoint(
publicClient: PublicClient,
parameters: ResolveServiceEndpointParameters,
): Promise<ResolvedServiceEndpoint> {
const agent = await resolveAgent(publicClient, {
agentId: parameters.agentId,
registryAddress: parameters.registryAddress,
})

if (!agent.agentURI) {
throw new Error(`Agent ${parameters.agentId} has no URI set`)
}

let file: AgentRegistrationFile
try {
file = await fetchRegistrationFile(agent.agentURI)
} catch (error) {
throw new Error(
`Agent ${parameters.agentId}: ${error instanceof Error ? error.message : String(error)}`,
)
}

const service = findService(file, parameters.serviceName)
if (!service) {
throw new Error(
`Service "${parameters.serviceName}" not found in agent ${parameters.agentId} registration file`,
)
}

return {
endpoint: service.endpoint,
service,
agentURI: agent.agentURI,
}
}
17 changes: 17 additions & 0 deletions src/registration/services.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { AgentRegistrationFile, ServiceEntry } from './types.js'

/** Return the first service whose name matches, or `undefined`. */
export function findService(
file: AgentRegistrationFile,
name: string,
): ServiceEntry | undefined {
return file.services.find((s) => s.name === name)
}

/** Return all services whose name matches. */
export function findServices(
file: AgentRegistrationFile,
name: string,
): ServiceEntry[] {
return file.services.filter((s) => s.name === name)
}
44 changes: 44 additions & 0 deletions src/registration/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type { Address } from 'viem'

export const REGISTRATION_TYPE =
'https://eips.ethereum.org/EIPS/eip-8004#registration-v1' as const

export interface AgentRegistrationFile {
type: typeof REGISTRATION_TYPE
name: string
description: string
image: string
services: ServiceEntry[]
active?: boolean
registrations?: RegistrationBinding[]
[key: string]: unknown
}

export interface ServiceEntry {
name: string
endpoint: string
version?: string
[key: string]: unknown
}

export interface RegistrationBinding {
agentId: bigint
agentRegistry: `eip155:${number}:0x${string}`
}

export type CreateRegistrationFileParameters = Omit<
AgentRegistrationFile,
'type'
>

export interface ResolveServiceEndpointParameters {
agentId: bigint
serviceName: string
registryAddress?: Address
}

export interface ResolvedServiceEndpoint {
endpoint: string
service: ServiceEntry
agentURI: string
}
Loading
Loading