diff --git a/src/__tests__/ens-discovery.test.ts b/src/__tests__/ens-discovery.test.ts new file mode 100644 index 0000000..cfd9ab6 --- /dev/null +++ b/src/__tests__/ens-discovery.test.ts @@ -0,0 +1,303 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import { + formatCAIP19ToolRef, + parseCAIP19ToolRef, +} from "../lib/discovery/caip19.js" +import { staticSubnameResolver } from "../lib/discovery/ens.js" + +const mockGetToolConfig = vi.fn() + +vi.mock("../lib/onchain/registry.js", () => ({ + ToolRegistryClient: class { + getToolConfig = mockGetToolConfig + }, +})) + +vi.mock("viem", async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + createPublicClient: () => ({ + readContract: vi.fn(), + }), + } +}) + +vi.mock("viem/ens", async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + getEnsText: vi.fn(), + } +}) + +afterEach(() => { + vi.restoreAllMocks() + mockGetToolConfig.mockReset() +}) + +describe("parseCAIP19ToolRef", () => { + it("parses a valid CAIP-19 tool reference", () => { + const ref = parseCAIP19ToolRef( + "eip155:8453/erc8257:0x265BB2DBFC0A8165C9A1941Eb1372F349baD2cf1/42", + ) + expect(ref.chainId).toBe(8453) + expect(ref.registryAddress).toBe( + "0x265bb2dbfc0a8165c9a1941eb1372f349bad2cf1", + ) + expect(ref.toolId).toBe(42n) + expect(ref.raw).toBe( + "eip155:8453/erc8257:0x265BB2DBFC0A8165C9A1941Eb1372F349baD2cf1/42", + ) + }) + + it("parses chain ID 1 (mainnet)", () => { + const ref = parseCAIP19ToolRef( + "eip155:1/erc8257:0xabcdefabcdefabcdefabcdefabcdefabcdefabcd/1", + ) + expect(ref.chainId).toBe(1) + expect(ref.toolId).toBe(1n) + }) + + it("throws on invalid format — missing namespace", () => { + expect(() => parseCAIP19ToolRef("8453/erc8257:0xabc/1")).toThrow( + "Invalid CAIP-19 tool reference", + ) + }) + + it("throws on invalid format — bad address", () => { + expect(() => parseCAIP19ToolRef("eip155:8453/erc8257:0xZZZ/1")).toThrow( + "Invalid CAIP-19 tool reference", + ) + }) + + it("throws on invalid format — missing tool ID", () => { + expect(() => + parseCAIP19ToolRef( + "eip155:8453/erc8257:0xabcdefabcdefabcdefabcdefabcdefabcdefabcd", + ), + ).toThrow("Invalid CAIP-19 tool reference") + }) + + it("throws on empty string", () => { + expect(() => parseCAIP19ToolRef("")).toThrow( + "Invalid CAIP-19 tool reference", + ) + }) +}) + +describe("formatCAIP19ToolRef", () => { + it("formats a tool ref to canonical string", () => { + const result = formatCAIP19ToolRef({ + raw: "eip155:8453/erc8257:0x265bb2dbfc0a8165c9a1941eb1372f349bad2cf1/42", + chainId: 8453, + registryAddress: "0x265bb2dbfc0a8165c9a1941eb1372f349bad2cf1", + toolId: 42n, + }) + expect(result).toBe( + "eip155:8453/erc8257:0x265bb2dbfc0a8165c9a1941eb1372f349bad2cf1/42", + ) + }) +}) + +describe("staticSubnameResolver", () => { + it("returns the provided subnames", async () => { + const resolver = staticSubnameResolver([ + "web.example.eth", + "api.example.eth", + ]) + const result = await resolver.resolveSubnames("example.eth") + expect(result).toEqual(["web.example.eth", "api.example.eth"]) + }) +}) + +describe("discoverToolsFromENS", () => { + it("discovers tools from ENS subnames with registrations", async () => { + const { getEnsText } = await import("viem/ens") + const mockedGetEnsText = vi.mocked(getEnsText) + + mockedGetEnsText.mockImplementation(async (_client, params) => { + const key = params.key + if (params.name === "web.example.eth") { + if (key === "registrations[0]") { + return "eip155:8453/erc8257:0x265bb2dbfc0a8165c9a1941eb1372f349bad2cf1/1" + } + if (key === "registrations[1]") return null + if ( + key === + "attestations[eip155:8453/erc8257:0x265bb2dbfc0a8165c9a1941eb1372f349bad2cf1/1]" + ) { + return "https://tools.example.com/.well-known/ens-attestation" + } + } + return null + }) + + mockGetToolConfig.mockResolvedValue({ + creator: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd", + metadataURI: "https://tools.example.com/manifest.json", + manifestHash: "0xdeadbeef", + accessPredicate: "0x0000000000000000000000000000000000000000", + }) + + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + if (url === "https://tools.example.com/manifest.json") { + return new Response( + JSON.stringify({ endpoint: "https://tools.example.com/api/v1" }), + { status: 200 }, + ) + } + return new Response("", { status: 404 }) + }), + ) + + const { discoverToolsFromENS } = await import("../lib/discovery/ens.js") + + const result = await discoverToolsFromENS({ + ensName: "example.eth", + subnameResolver: staticSubnameResolver(["web.example.eth"]), + }) + + expect(result.ensName).toBe("example.eth") + expect(result.applications).toHaveLength(1) + expect(result.applications[0].name).toBe("web.example.eth") + expect(result.applications[0].registrations).toHaveLength(1) + expect(result.tools).toHaveLength(1) + expect(result.tools[0].caip19.toolId).toBe(1n) + expect(result.tools[0].originVerification.toolOrigin).toBe( + "https://tools.example.com", + ) + expect(result.tools[0].originVerification.ensAttestationOrigin).toBe( + "https://tools.example.com", + ) + expect(result.tools[0].originVerification.verified).toBe(true) + }) + + it("reports verification failure when origins do not match", async () => { + const { getEnsText } = await import("viem/ens") + const mockedGetEnsText = vi.mocked(getEnsText) + + mockedGetEnsText.mockImplementation(async (_client, params) => { + const key = params.key + if (params.name === "app.example.eth") { + if (key === "registrations[0]") { + return "eip155:1/erc8257:0xabcdefabcdefabcdefabcdefabcdefabcdefabcd/5" + } + if (key === "registrations[1]") return null + if ( + key === + "attestations[eip155:1/erc8257:0xabcdefabcdefabcdefabcdefabcdefabcdefabcd/5]" + ) { + return "https://malicious.com/.well-known/ens-attestation" + } + } + return null + }) + + mockGetToolConfig.mockResolvedValue({ + creator: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd", + metadataURI: "https://real-app.example.com/manifest.json", + manifestHash: "0xdeadbeef", + accessPredicate: "0x0000000000000000000000000000000000000000", + }) + + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response( + JSON.stringify({ + endpoint: "https://real-app.example.com/tools/v1", + }), + { status: 200 }, + ), + ), + ) + + const { discoverToolsFromENS } = await import("../lib/discovery/ens.js") + + const result = await discoverToolsFromENS({ + ensName: "example.eth", + subnameResolver: staticSubnameResolver(["app.example.eth"]), + }) + + expect(result.tools).toHaveLength(1) + expect(result.tools[0].originVerification.verified).toBe(false) + expect(result.tools[0].originVerification.toolOrigin).toBe( + "https://real-app.example.com", + ) + expect(result.tools[0].originVerification.ensAttestationOrigin).toBe( + "https://malicious.com", + ) + }) + + it("handles subnames with no registrations gracefully", async () => { + const { getEnsText } = await import("viem/ens") + const mockedGetEnsText = vi.mocked(getEnsText) + mockedGetEnsText.mockResolvedValue(null) + + const { discoverToolsFromENS } = await import("../lib/discovery/ens.js") + + const result = await discoverToolsFromENS({ + ensName: "empty.eth", + subnameResolver: staticSubnameResolver(["sub.empty.eth"]), + }) + + expect(result.applications).toHaveLength(0) + expect(result.tools).toHaveLength(0) + expect(result.errors).toHaveLength(0) + }) + + it("collects errors without failing the entire traversal", async () => { + const { getEnsText } = await import("viem/ens") + const mockedGetEnsText = vi.mocked(getEnsText) + + mockedGetEnsText.mockImplementation(async (_client, params) => { + if (params.name === "broken.example.eth") { + throw new Error("resolver not found") + } + if (params.name === "ok.example.eth") { + if (params.key === "registrations[0]") { + return "eip155:8453/erc8257:0x265bb2dbfc0a8165c9a1941eb1372f349bad2cf1/99" + } + if (params.key === "registrations[1]") return null + return null + } + return null + }) + + mockGetToolConfig.mockResolvedValue({ + creator: "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd", + metadataURI: "https://ok.example.com/manifest.json", + manifestHash: "0xdeadbeef", + accessPredicate: "0x0000000000000000000000000000000000000000", + }) + + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response( + JSON.stringify({ endpoint: "https://ok.example.com/api" }), + { status: 200 }, + ), + ), + ) + + const { discoverToolsFromENS } = await import("../lib/discovery/ens.js") + + const result = await discoverToolsFromENS({ + ensName: "example.eth", + subnameResolver: staticSubnameResolver([ + "broken.example.eth", + "ok.example.eth", + ]), + }) + + expect(result.errors.length).toBeGreaterThan(0) + expect(result.errors[0].phase).toBe("schema-read") + expect(result.tools).toHaveLength(1) + }) +}) diff --git a/src/index.ts b/src/index.ts index b2316ee..09282dc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -152,6 +152,24 @@ export { WALLET_PROVIDERS, walletAdapterToClient, } from "./lib/wallet/index.js" +export type { + ApplicationSubname, + CAIP19ToolRef, + DiscoveredTool, + ENSDiscoveryError, + ENSDiscoveryOptions, + ENSDiscoveryResult, + ENSToolConfig, + OriginVerification, + SubnameResolver, +} from "./lib/discovery/index.js" +export { + discoverToolsFromENS, + formatCAIP19ToolRef, + parseCAIP19ToolRef, + staticSubnameResolver, + subgraphSubnameResolver, +} from "./lib/discovery/index.js" export type { GateMiddleware, ToolContext, diff --git a/src/lib/discovery/caip19.ts b/src/lib/discovery/caip19.ts new file mode 100644 index 0000000..de5954c --- /dev/null +++ b/src/lib/discovery/caip19.ts @@ -0,0 +1,34 @@ +import type { Address } from "viem" +import type { CAIP19ToolRef } from "./types.js" + +/** + * Parses a CAIP-19 asset identifier for an ERC-8257 tool registration. + * + * Expected format: `eip155:/erc8257:/` + * + * @throws if the string does not match the expected format. + */ +export function parseCAIP19ToolRef(raw: string): CAIP19ToolRef { + const match = raw.match( + /^eip155:(\d+)\/erc8257:(0x[0-9a-fA-F]{40})\/(\d+)$/, + ) + if (!match) { + throw new Error( + `Invalid CAIP-19 tool reference: "${raw}". ` + + "Expected format: eip155:/erc8257:/", + ) + } + return { + raw, + chainId: Number(match[1]), + registryAddress: match[2].toLowerCase() as Address, + toolId: BigInt(match[3]), + } +} + +/** + * Formats a CAIP-19 tool reference back to its canonical string form. + */ +export function formatCAIP19ToolRef(ref: CAIP19ToolRef): string { + return `eip155:${ref.chainId}/erc8257:${ref.registryAddress}/${ref.toolId}` +} diff --git a/src/lib/discovery/ens.ts b/src/lib/discovery/ens.ts new file mode 100644 index 0000000..06b5d4f --- /dev/null +++ b/src/lib/discovery/ens.ts @@ -0,0 +1,343 @@ +import { type Chain, createPublicClient, http, namehash } from "viem" +import { base, mainnet } from "viem/chains" +import { getEnsText, normalize } from "viem/ens" +import { ToolRegistryClient } from "../onchain/registry.js" +import { parseCAIP19ToolRef } from "./caip19.js" +import type { + ApplicationSubname, + CAIP19ToolRef, + DiscoveredTool, + ENSDiscoveryError, + ENSDiscoveryOptions, + ENSDiscoveryResult, + OriginVerification, + SubnameResolver, + ToolConfig, +} from "./types.js" + +/** + * The maximum number of registration slots to probe per subname. + * Prevents runaway reads against malformed ENS records. + */ +const MAX_REGISTRATION_SLOTS = 64 + +const FETCH_TIMEOUT_MS = 5_000 +const SUBGRAPH_PAGE_SIZE = 100 +const MAX_SUBGRAPH_PAGES = 10 + +/** Known chains for resolving CAIP-19 chain IDs to viem Chain objects. */ +const CHAIN_BY_ID: Record = { + [mainnet.id]: mainnet, + [base.id]: base, +} + +/** + * Creates a simple static subname resolver from an array of known subnames. + * Useful for testing or when subnames are already known. + */ +export function staticSubnameResolver(subnames: string[]): SubnameResolver { + return { + async resolveSubnames() { + return subnames + }, + } +} + +/** + * Default subname resolver using the ENS subgraph on The Graph Network. + * Paginates through all first-level subnames of the given ENS name. + */ +export function subgraphSubnameResolver( + subgraphUrl = "https://gateway.thegraph.com/api/subgraphs/id/5XqPmWe6gjyrJtFn9cLy237i4cWw2j9HcUJEXsP5qGtH", +): SubnameResolver { + return { + async resolveSubnames(ensName: string): Promise { + const node = namehash(normalize(ensName)) + const allSubnames: string[] = [] + let lastId = "" + + for (let page = 0; page < MAX_SUBGRAPH_PAGES; page++) { + const whereClause = lastId + ? `{ parentDomain: "${node}", id_gt: "${lastId}" }` + : `{ parentDomain: "${node}" }` + const query = `{ + domains(where: ${whereClause}, first: ${SUBGRAPH_PAGE_SIZE}, orderBy: id) { + id + name + } + }` + + const response = await fetch(subgraphUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query }), + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }) + + if (!response.ok) { + throw new Error( + `ENS subgraph query failed: ${response.status} ${response.statusText}`, + ) + } + + const json = (await response.json()) as { + data?: { domains?: { id: string; name: string }[] } + } + const domains = json.data?.domains ?? [] + for (const d of domains) { + allSubnames.push(d.name) + } + + if (domains.length < SUBGRAPH_PAGE_SIZE) break + lastId = domains[domains.length - 1].id + } + + return allSubnames + }, + } +} + +/** + * Discovers ERC-8257 tools registered under an ENS name by traversing + * subnames, reading Application schemas, fetching tool configs from the + * onchain registry, and verifying origin attestations on both ends. + * + * This implements the discovery flow described in the ENS × ERC-8257 proposal: + * + * 1. Given an ENS name, walk subnames. + * 2. Discover Application schemas (subnames with `registrations[*]` text records). + * 3. Enumerate CAIP-19 registrations. + * 4. Fetch each tool from the 8257 registry. + * 5. Verify origin attestations on both ends (tool endpoint ↔ ENS attestation). + * + * @example + * ```ts + * import { discoverToolsFromENS } from "@opensea/tool-sdk" + * + * const result = await discoverToolsFromENS({ + * ensName: "uniswap.eth", + * }) + * + * for (const tool of result.tools) { + * console.log(tool.caip19.raw, tool.originVerification.verified) + * } + * ``` + */ +export async function discoverToolsFromENS( + options: ENSDiscoveryOptions, +): Promise { + const ensChain = options.ensChain ?? mainnet + const registrationsKey = options.registrationsKey ?? "registrations" + const attestationsKey = options.attestationsKey ?? "attestations" + const errors: ENSDiscoveryError[] = [] + + const ensClient = createPublicClient({ + chain: ensChain, + transport: http(options.ensRpcUrl), + }) + + // Step 1: Resolve subnames + const resolver = + options.subnameResolver ?? + subgraphSubnameResolver(options.subgraphUrl) + + let subnames: string[] + try { + subnames = await resolver.resolveSubnames(options.ensName) + } catch (err) { + errors.push({ + phase: "subname-resolution", + context: options.ensName, + message: err instanceof Error ? err.message : String(err), + }) + return { ensName: options.ensName, applications: [], tools: [], errors } + } + + // Step 2: For each subname, read registrations text records + const applications: ApplicationSubname[] = [] + + for (const subname of subnames) { + const registrations: CAIP19ToolRef[] = [] + try { + for (let i = 0; i < MAX_REGISTRATION_SLOTS; i++) { + const key = `${registrationsKey}[${i}]` + const value = await getEnsText(ensClient, { + name: normalize(subname), + key, + }) + if (!value) break + try { + registrations.push(parseCAIP19ToolRef(value)) + } catch (parseErr) { + errors.push({ + phase: "schema-read", + context: `${subname} / ${key}`, + message: + parseErr instanceof Error ? parseErr.message : String(parseErr), + }) + } + } + } catch (err) { + errors.push({ + phase: "schema-read", + context: subname, + message: err instanceof Error ? err.message : String(err), + }) + continue + } + + if (registrations.length > 0) { + applications.push({ name: subname, registrations }) + } + } + + // Step 3 & 4: Fetch tool configs and verify attestations + const tools: DiscoveredTool[] = [] + + for (const app of applications) { + for (const ref of app.registrations) { + // Fetch tool config from registry using the chain encoded in CAIP-19 + let config: ToolConfig + try { + const refChain = resolveChain(ref.chainId, options.registryChain) + const registry = new ToolRegistryClient({ + chain: refChain, + rpcUrl: options.registryRpcUrl, + registryAddress: ref.registryAddress, + }) + config = await registry.getToolConfig(ref.toolId) + } catch (err) { + errors.push({ + phase: "registry-fetch", + context: ref.raw, + message: err instanceof Error ? err.message : String(err), + }) + continue + } + + // Step 5: Verify origin attestations + let originVerification: OriginVerification + try { + originVerification = await verifyOriginAttestation({ + ensClient, + subname: app.name, + caip19Raw: ref.raw, + toolConfig: config, + attestationsKey, + }) + } catch (err) { + errors.push({ + phase: "attestation-verify", + context: ref.raw, + message: err instanceof Error ? err.message : String(err), + }) + originVerification = { + toolOrigin: extractOrigin(config.metadataURI) ?? "", + ensAttestationUrl: null, + ensAttestationOrigin: null, + verified: false, + } + } + + tools.push({ + sourceName: app.name, + caip19: ref, + config, + originVerification, + }) + } + } + + return { ensName: options.ensName, applications, tools, errors } +} + +/** + * Verifies the two-way origin link: + * - Tool side: extract the origin from the tool's metadataURI or endpoint + * - ENS side: read `attestations[{CAIP-19}]` text record, extract origin from that URL + * - If both origins match, the loop is closed. + */ +async function verifyOriginAttestation(params: { + ensClient: ReturnType + subname: string + caip19Raw: string + toolConfig: ToolConfig + attestationsKey: string +}): Promise { + const { ensClient, subname, caip19Raw, toolConfig, attestationsKey } = params + + // Derive the tool's origin from the metadataURI. + // If metadataURI is an IPFS/content-hash URI, we try to fetch the manifest + // to get the endpoint. For https URIs, extract origin directly. + let toolOrigin = extractOrigin(toolConfig.metadataURI) ?? "" + + // Try to fetch the manifest to get the endpoint origin (more authoritative). + if (toolConfig.metadataURI.startsWith("https://")) { + try { + const resp = await fetch(toolConfig.metadataURI, { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }) + if (resp.ok) { + const manifest = (await resp.json()) as { endpoint?: string } + if (manifest.endpoint) { + toolOrigin = extractOrigin(manifest.endpoint) ?? toolOrigin + } + } + } catch { + // Use metadataURI origin as fallback + } + } + + // Read ENS attestation record + const attestationKey = `${attestationsKey}[${caip19Raw}]` + let ensAttestationUrl: string | null = null + try { + ensAttestationUrl = await getEnsText(ensClient, { + name: normalize(subname), + key: attestationKey, + }) + } catch { + // Record may not exist + } + + const ensAttestationOrigin = ensAttestationUrl + ? extractOrigin(ensAttestationUrl) + : null + + const verified = + !!toolOrigin && + !!ensAttestationOrigin && + toolOrigin === ensAttestationOrigin + + return { + toolOrigin, + ensAttestationUrl, + ensAttestationOrigin, + verified, + } +} + +/** + * Resolves a CAIP-19 chain ID to a viem Chain object. + * Throws if the chain is not in the lookup map and no fallback is provided. + */ +function resolveChain(chainId: number, fallback?: Chain): Chain { + const chain = CHAIN_BY_ID[chainId] ?? fallback + if (!chain) { + throw new Error( + `Unsupported chain ID ${chainId} in CAIP-19 reference. ` + + `Supported chains: ${Object.keys(CHAIN_BY_ID).join(", ")}. ` + + `Pass a registryChain option to handle this chain.`, + ) + } + return chain +} + +function extractOrigin(url: string): string | null { + try { + const parsed = new URL(url) + return parsed.origin + } catch { + return null + } +} diff --git a/src/lib/discovery/index.ts b/src/lib/discovery/index.ts new file mode 100644 index 0000000..9ac262d --- /dev/null +++ b/src/lib/discovery/index.ts @@ -0,0 +1,17 @@ +export { parseCAIP19ToolRef, formatCAIP19ToolRef } from "./caip19.js" +export { + discoverToolsFromENS, + staticSubnameResolver, + subgraphSubnameResolver, +} from "./ens.js" +export type { + ApplicationSubname, + CAIP19ToolRef, + DiscoveredTool, + ENSDiscoveryError, + ENSDiscoveryOptions, + ENSDiscoveryResult, + OriginVerification, + SubnameResolver, + ToolConfig as ENSToolConfig, +} from "./types.js" diff --git a/src/lib/discovery/types.ts b/src/lib/discovery/types.ts new file mode 100644 index 0000000..cc8e839 --- /dev/null +++ b/src/lib/discovery/types.ts @@ -0,0 +1,134 @@ +import type { Address, Chain, Hex } from "viem" + +/** + * A parsed CAIP-19 asset identifier for an ERC-8257 tool registration. + * + * Format: `eip155:/erc8257:/` + */ +export interface CAIP19ToolRef { + /** The raw CAIP-19 string. */ + raw: string + /** EVM chain ID (e.g. 1, 8453). */ + chainId: number + /** Registry contract address. */ + registryAddress: Address + /** Onchain tool ID. */ + toolId: bigint +} + +/** + * An ENS subname discovered under the root name that carries an Application + * schema with tool registrations. + */ +export interface ApplicationSubname { + /** Fully-qualified ENS name (e.g. "web.uniswap.eth"). */ + name: string + /** CAIP-19 tool references declared in the Application schema. */ + registrations: CAIP19ToolRef[] +} + +/** + * Result of verifying the two-way origin link between an ENS attestation and + * the tool's own origin attestation. + */ +export interface OriginVerification { + /** Origin derived from the tool's manifest endpoint. */ + toolOrigin: string + /** Attestation URL retrieved from the ENS `attestations[{CAIP-19}]` record. */ + ensAttestationUrl: string | null + /** Origin of the ENS attestation URL. */ + ensAttestationOrigin: string | null + /** Whether both origins match (the loop is closed). */ + verified: boolean +} + +/** Onchain tool configuration from the ERC-8257 registry. */ +export interface ToolConfig { + creator: Address + metadataURI: string + manifestHash: Hex + accessPredicate: Address +} + +/** A fully resolved tool discovered via ENS traversal. */ +export interface DiscoveredTool { + /** The Application subname this tool was found under. */ + sourceName: string + /** CAIP-19 identifier. */ + caip19: CAIP19ToolRef + /** Onchain tool config from the 8257 registry. */ + config: ToolConfig + /** Origin verification result. */ + originVerification: OriginVerification +} + +/** Full result of the ENS discovery traversal. */ +export interface ENSDiscoveryResult { + /** The root ENS name queried. */ + ensName: string + /** Subnames that were identified as Applications. */ + applications: ApplicationSubname[] + /** All tools discovered and verified. */ + tools: DiscoveredTool[] + /** Errors encountered during traversal (non-fatal). */ + errors: ENSDiscoveryError[] +} + +export interface ENSDiscoveryError { + /** Which phase the error occurred in. */ + phase: "subname-resolution" | "schema-read" | "registry-fetch" | "attestation-verify" + /** Context (e.g. the subname or CAIP-19 reference). */ + context: string + /** Error message. */ + message: string +} + +/** + * Pluggable interface for resolving subnames under an ENS name. + * ENS does not provide onchain subname enumeration, so consumers can + * supply their own data source (subgraph, API, static list, etc.). + */ +export interface SubnameResolver { + /** + * Returns the fully-qualified subnames under the given ENS name. + * Should NOT include the root name itself. + */ + resolveSubnames(ensName: string): Promise +} + +/** + * Configuration for the ENS discovery traversal. + */ +export interface ENSDiscoveryOptions { + /** Root ENS name to traverse (e.g. "uniswap.eth"). */ + ensName: string + /** Chain for ENS resolution (defaults to Ethereum mainnet). */ + ensChain?: Chain + /** RPC URL for ENS resolution (L1). */ + ensRpcUrl?: string + /** Chain where the 8257 registry is deployed (defaults to Base). */ + registryChain?: Chain + /** RPC URL for registry reads. */ + registryRpcUrl?: string + /** + * Subname resolver implementation. If not provided, uses the ENS subgraph. + * Pass a static list via `staticSubnameResolver(["web.example.eth", ...])`. + */ + subnameResolver?: SubnameResolver + /** + * ENS subgraph URL. Used by the default subgraph resolver when no custom + * `subnameResolver` is provided. + * Defaults to the public ENS subgraph on The Graph Network. + */ + subgraphUrl?: string + /** + * Text record key prefix for the registrations array. + * Defaults to "registrations" (reads "registrations[0]", "registrations[1]", etc.). + */ + registrationsKey?: string + /** + * Text record key prefix for attestation lookups. + * Defaults to "attestations" (reads "attestations[{CAIP-19}]"). + */ + attestationsKey?: string +}