From cf827772ac518f429a6824f509f95ab312c0fc9f Mon Sep 17 00:00:00 2001 From: sevenzing Date: Sun, 31 May 2026 18:44:16 +0300 Subject: [PATCH 01/19] initial profile records parsing --- apps/ensapi/src/omnigraph-api/builder.ts | 4 + .../lib/resolution/profile/README.md | 82 +++++++ .../profile/build-profile-selection.test.ts | 141 ++++++++++++ .../profile/build-profile-selection.ts | 119 ++++++++++ .../profile/parsers/addresses.test.ts | 52 +++++ .../resolution/profile/parsers/addresses.ts | 50 +++++ .../resolution/profile/parsers/images.test.ts | 88 ++++++++ .../lib/resolution/profile/parsers/images.ts | 69 ++++++ .../lib/resolution/profile/parsers/index.ts | 22 ++ .../resolution/profile/parsers/social.test.ts | 203 ++++++++++++++++++ .../lib/resolution/profile/parsers/social.ts | 117 ++++++++++ .../profile/parsers/test-helpers.ts | 10 + .../resolution/profile/parsers/texts.test.ts | 44 ++++ .../lib/resolution/profile/parsers/texts.ts | 13 ++ .../lib/resolution/profile/parsers/types.ts | 16 ++ .../lib/resolution/records-selection.test.ts | 33 +++ .../lib/resolution/records-selection.ts | 32 +++ .../schema/domain.integration.test.ts | 49 +++-- .../ensapi/src/omnigraph-api/schema/domain.ts | 19 +- .../omnigraph-api/schema/forward-resolve.ts | 15 +- .../schema/primary-name-record.ts | 19 +- .../src/omnigraph-api/schema/profile.ts | 111 +++++----- .../src/omnigraph-api/schema/scalars.ts | 28 +++ .../react/omnigraph/_lib/cache-exchange.ts | 2 +- .../src/lib/ens-metadata-service.test.ts | 31 +++ .../enssdk/src/lib/ens-metadata-service.ts | 53 +++++ packages/enssdk/src/lib/index.ts | 1 + packages/enssdk/src/lib/types/addresses.ts | 9 + packages/enssdk/src/lib/types/index.ts | 1 + .../src/omnigraph/generated/introspection.ts | 57 +++-- .../src/omnigraph/generated/schema.graphql | 75 ++++--- packages/enssdk/src/omnigraph/graphql.ts | 4 + .../src/components/identity/EnsAvatar.tsx | 2 +- packages/namehash-ui/src/index.ts | 3 +- packages/namehash-ui/src/utils/ensMetadata.ts | 37 ---- 35 files changed, 1438 insertions(+), 173 deletions(-) create mode 100644 apps/ensapi/src/omnigraph-api/lib/resolution/profile/README.md create mode 100644 apps/ensapi/src/omnigraph-api/lib/resolution/profile/build-profile-selection.test.ts create mode 100644 apps/ensapi/src/omnigraph-api/lib/resolution/profile/build-profile-selection.ts create mode 100644 apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/addresses.test.ts create mode 100644 apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/addresses.ts create mode 100644 apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/images.test.ts create mode 100644 apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/images.ts create mode 100644 apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/index.ts create mode 100644 apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/social.test.ts create mode 100644 apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/social.ts create mode 100644 apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/test-helpers.ts create mode 100644 apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/texts.test.ts create mode 100644 apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/texts.ts create mode 100644 apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/types.ts create mode 100644 packages/enssdk/src/lib/ens-metadata-service.test.ts create mode 100644 packages/enssdk/src/lib/ens-metadata-service.ts create mode 100644 packages/enssdk/src/lib/types/addresses.ts delete mode 100644 packages/namehash-ui/src/utils/ensMetadata.ts diff --git a/apps/ensapi/src/omnigraph-api/builder.ts b/apps/ensapi/src/omnigraph-api/builder.ts index fe1b81b556..6af1452606 100644 --- a/apps/ensapi/src/omnigraph-api/builder.ts +++ b/apps/ensapi/src/omnigraph-api/builder.ts @@ -8,6 +8,7 @@ import { AttributeNames, createOpenTelemetryWrapper } from "@pothos/tracing-open import type { BeautifiedLabel, BeautifiedName, + BitcoinAddress, ChainId, CoinType, DomainId, @@ -26,6 +27,7 @@ import type { RenewalId, ResolverId, ResolverRecordsId, + SolanaAddress, } from "enssdk"; import { getNamedType } from "graphql"; import superjson from "superjson"; @@ -65,6 +67,8 @@ export type BuilderScalars = { BigInt: { Input: bigint; Output: bigint }; JSON: { Input: JsonValue; Output: JsonValue }; Address: { Input: NormalizedAddress; Output: NormalizedAddress }; + BitcoinAddress: { Input: BitcoinAddress; Output: BitcoinAddress }; + SolanaAddress: { Input: SolanaAddress; Output: SolanaAddress }; Hex: { Input: Hex; Output: Hex }; ChainId: { Input: ChainId; Output: ChainId }; CoinType: { Input: CoinType; Output: CoinType }; diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/README.md b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/README.md new file mode 100644 index 0000000000..6852c69cf0 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/README.md @@ -0,0 +1,82 @@ +# Domain profile resolution + +Interpreted ENS profile fields exposed on `Domain.profile` (and `PrimaryNameRecord.profile`) in the Omnigraph API. Raw resolver records are resolved in one round-trip; each GraphQL field is backed by a `ProfileFieldParser` that declares its record selection and parsing logic. + +## Architecture + +``` +profile/ + build-profile-selection.ts # GraphQL selection → ResolverRecordsSelection + parsers/ + types.ts # ProfileFieldParser + texts.ts # Simple text passthrough parsers + images.ts # avatar / header → httpUrl (direct HTTP + metadata service) + social.ts # Service keys → { handle, httpUrl } + addresses.ts # Multicoin addresses → typed address strings + README.md +``` + +GraphQL wiring lives in `apps/ensapi/src/omnigraph-api/schema/profile.ts`. + +Each parser is a singleton with: + +- `selection` — which text keys / coin types must be fetched +- `parse(records)` — derive the GraphQL output from `ResolvedRecordsModel` + +`buildProfileSelectionFromResolveContainerInfo` merges parser selections based on the client's `profile { ... }` sub-selection. + +## Records roadmap + +Record names use a `texts.` prefix for ENS text records and `addresses.` for multicoin address records. GraphQL output paths are noted in the description where they differ from the on-chain key. + +| Record name | Status | ENSIP | Description | +| ------------------------------------------------- | ------ | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `texts.description` | ✅ | [18](https://docs.ens.domains/ensip/18), [5](https://docs.ens.domains/ensip/5) | Biography text. GraphQL: `profile.description`. Parser: `ProfileDescriptionParser`. | +| `texts.avatar` | ✅ | [18](https://docs.ens.domains/ensip/18), [12](https://docs.ens.domains/ensip/12) | Avatar image ([ENSIP-12](https://docs.ens.domains/ensip/12)). GraphQL: `profile.avatar.httpUrl`. Direct `http(s)://` or ENS Metadata Service fallback. Parser: `ProfileAvatarParser`. | +| `texts.header` | ✅ | [18](https://docs.ens.domains/ensip/18), [12](https://docs.ens.domains/ensip/12) | Header / banner image ([ENSIP-12](https://docs.ens.domains/ensip/12)). GraphQL: `profile.header.httpUrl`. Parser: `ProfileHeaderParser`. | +| `texts.url` | ✅ | [18](https://docs.ens.domains/ensip/18), [5](https://docs.ens.domains/ensip/5) | Website URL. GraphQL: `profile.website.httpUrl`. Parser: `ProfileWebsiteParser`. | +| `texts.com.github` | ✅ | [18](https://docs.ens.domains/ensip/18), [5](https://docs.ens.domains/ensip/5) | GitHub username or repo URL. GraphQL: `profile.socials.github`. Parser: `SocialGithubParser`. | +| `texts.com.twitter` | ✅ | [18](https://docs.ens.domains/ensip/18), [5](https://docs.ens.domains/ensip/5) | Twitter / X handle or URL. GraphQL: `profile.socials.twitter`. Parser: `SocialTwitterParser`. | +| `texts.org.telegram` | ✅ | [18](https://docs.ens.domains/ensip/18), [5](https://docs.ens.domains/ensip/5) | Telegram handle or URL. GraphQL: `profile.socials.telegram`. Parser: `SocialTelegramParser`. | +| `addresses.ethereum` | ✅ | [9](https://docs.ens.domains/ensip/9) | Ethereum address (`coinType` 60). GraphQL: `profile.addresses.ethereum`. Parser: `ProfileAddressEthereumParser`. | +| `addresses.base` | ✅ | [11](https://docs.ens.domains/ensip/11) | Base address (`coinType` 2147492101). GraphQL: `profile.addresses.base`. Parser: `ProfileAddressBaseParser`. | +| `addresses.bitcoin` | ✅ | [9](https://docs.ens.domains/ensip/9) | Bitcoin address (`coinType` 0). GraphQL: `profile.addresses.bitcoin`. Parser: `ProfileAddressBitcoinParser`. | +| `addresses.solana` | ✅ | [9](https://docs.ens.domains/ensip/9) | Solana address (`coinType` 501). GraphQL: `profile.addresses.solana`. Parser: `ProfileAddressSolanaParser`. | +| `texts.theme` | 📋 | [18](https://docs.ens.domains/ensip/18) | Comma-separated hex colour scheme (`background,text,accent,accentText,border`). | +| `texts.email` | 📋 | [18](https://docs.ens.domains/ensip/18), [5](https://docs.ens.domains/ensip/5) | Contact email address. | +| `texts.location` | 📋 | [18](https://docs.ens.domains/ensip/18), [5](https://docs.ens.domains/ensip/5) | Human-readable location (e.g. `Melbourne, Australia`). | +| `texts.timezone` | 📋 | [18](https://docs.ens.domains/ensip/18) | tz database timezone (e.g. `Australia/Melbourne`). | +| `texts.language` | 📋 | [18](https://docs.ens.domains/ensip/18) | ISO 639-1 two-letter language code. | +| `texts.primary-contact` | 📋 | [18](https://docs.ens.domains/ensip/18) | Record key of the primary contact (e.g. `com.github`, `email`). | +| `texts.keywords` | 📋 | [5](https://docs.ens.domains/ensip/5) | Comma-separated keywords, most significant first. | +| `texts.mail` | 📋 | [5](https://docs.ens.domains/ensip/5) | Physical mailing address. | +| `texts.notice` | 📋 | [5](https://docs.ens.domains/ensip/5) | Notice regarding the name. | +| `texts.phone` | 📋 | [5](https://docs.ens.domains/ensip/5) | Phone number as E.164 string. | +| `texts.com.linkedin` | 📋 | [5](https://docs.ens.domains/ensip/5) | LinkedIn username. | +| `texts.com.peepeth` | 📋 | [5](https://docs.ens.domains/ensip/5) | Peepeth username. | +| `texts.io.keybase` | 📋 | [5](https://docs.ens.domains/ensip/5) | Keybase username. | +| `texts.vnd.github` | 📋 | [5](https://docs.ens.domains/ensip/5) | Legacy GitHub key (renamed to `com.github`). Planned as fallback when `com.github` is unset. | +| `texts.vnd.twitter` | 📋 | [5](https://docs.ens.domains/ensip/5) | Legacy Twitter key (renamed to `com.twitter`). Planned as fallback when `com.twitter` is unset. | +| `texts.vnd.peepeth` | 📋 | [5](https://docs.ens.domains/ensip/5) | Legacy Peepeth key (renamed to `com.peepeth`). Planned as fallback when `com.peepeth` is unset. | +| `contenthash` | 📋 | [7](https://docs.ens.domains/ensip/7) | IPFS / Swarm content address. Resolved on `records.contenthash` today; not yet exposed on `profile`. | +| `texts.agent-registration[][]` | 📋 | [25](https://docs.ens.domains/ensip/25) | Non-empty attestation linking an ENS name to an on-chain AI agent registry entry. | +| `texts.agent-context` | 📋 | [26](https://docs.ens.domains/ensip/26) | Agent description and discovery entry point (plain text, Markdown, YAML, JSON, …). | +| `texts.agent-endpoint[mcp]` | 📋 | [26](https://docs.ens.domains/ensip/26) | Model Context Protocol endpoint URL. | +| `texts.agent-endpoint[a2a]` | 📋 | [26](https://docs.ens.domains/ensip/26) | Agent-to-Agent protocol endpoint URL. | +| `texts.agent-endpoint[web]` | 📋 | [26](https://docs.ens.domains/ensip/26) | Human-facing web interface URL. | +| `texts.alias` | ➖ | [18](https://docs.ens.domains/ensip/18) | Display alias. Not planned — ENSv2 will define aliases differently. | +| `texts.name` | ➖ | [18](https://docs.ens.domains/ensip/18) | Legacy alias key superseded by `alias`. Not planned — ENSv2 will define aliases differently. | +| `texts.display` | ➖ | [5](https://docs.ens.domains/ensip/5) | Canonical display name. Not planned. | +| `addresses.*` (other coin types) | ➖ | [9](https://docs.ens.domains/ensip/9), [11](https://docs.ens.domains/ensip/11) | Additional multicoin addresses beyond ETH, Base, BTC, SOL. Not planned. | + +**Status legend:** ✅ done · 📋 planned · ➖ not planned + +Per [ENSIP-18](https://docs.ens.domains/ensip/18), profile service values should omit optional formatting (`@`, `/u/`, …) where possible; parsers should tolerate bare handles and full URLs. + +## Adding a new profile field + +1. Implement a `ProfileFieldParser` in the appropriate `parsers/*.ts` module (or add a new module). +2. Export it from `parsers/index.ts`. +3. Register selection merging in `build-profile-selection.ts`. +4. Wire the GraphQL field in `schema/profile.ts`. +5. Add table-driven tests (`it.each` OK + ERR rows) — see `.memory-bank/skills/table-driven-unit-tests/SKILL.md`. diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/build-profile-selection.test.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/build-profile-selection.test.ts new file mode 100644 index 0000000000..97741fa94b --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/build-profile-selection.test.ts @@ -0,0 +1,141 @@ +import { + type FieldNode, + GraphQLObjectType, + type GraphQLResolveInfo, + GraphQLString, + parse, +} from "graphql"; +import { describe, expect, it } from "vitest"; + +import { buildProfileSelectionFromResolveContainerInfo } from "./build-profile-selection"; + +const DomainProfileType = new GraphQLObjectType({ + name: "DomainProfile", + fields: { + description: { type: GraphQLString }, + avatar: { type: GraphQLString }, + header: { type: GraphQLString }, + website: { type: GraphQLString }, + socials: { type: GraphQLString }, + addresses: { type: GraphQLString }, + }, +}); + +const DomainResolveType = new GraphQLObjectType({ + name: "DomainResolve", + fields: { + profile: { type: DomainProfileType }, + records: { type: GraphQLString }, + }, +}); + +function parseResolveFieldNode(subselection: string): FieldNode { + const document = parse(`{ resolve { ${subselection} } }`); + const operation = document.definitions[0]; + if (operation?.kind !== "OperationDefinition") throw new Error("expected operation"); + const resolveField = operation.selectionSet.selections[0]; + if (resolveField?.kind !== "Field") throw new Error("expected field"); + return resolveField; +} + +function resolveInfoForSubselection(subselection: string): GraphQLResolveInfo { + return { + fieldNodes: [parseResolveFieldNode(subselection)], + fragments: {}, + returnType: DomainResolveType, + variableValues: {}, + } as unknown as GraphQLResolveInfo; +} + +describe("buildProfileSelectionFromResolveContainerInfo", () => { + it.each([ + ["description", "profile { description }", { texts: ["description"] }], + ["avatar", "profile { avatar { httpUrl } }", { texts: ["avatar"] }], + ["header", "profile { header { httpUrl } }", { texts: ["header"] }], + ["website", "profile { website { httpUrl } }", { texts: ["url"] }], + [ + "socials.github", + "profile { socials { github { handle httpUrl } } }", + { texts: ["com.github"] }, + ], + [ + "socials.twitter", + "profile { socials { twitter { handle httpUrl } } }", + { texts: ["com.twitter"] }, + ], + [ + "socials.telegram", + "profile { socials { telegram { handle httpUrl } } }", + { texts: ["org.telegram"] }, + ], + ["addresses.ethereum", "profile { addresses { ethereum } }", { addresses: [60] }], + ["addresses.base", "profile { addresses { base } }", { addresses: [2147492101] }], + ["addresses.bitcoin", "profile { addresses { bitcoin } }", { addresses: [0] }], + ["addresses.solana", "profile { addresses { solana } }", { addresses: [501] }], + [ + "multiple profile sub-fields", + ` + profile { + description + avatar { httpUrl } + socials { + github { handle } + twitter { handle } + } + addresses { + ethereum + bitcoin + } + } + `, + { + texts: ["description", "avatar", "com.github", "com.twitter"], + addresses: [60, 0], + }, + ], + ])("builds selection for %s", (_message, subselection, expected) => { + expect( + buildProfileSelectionFromResolveContainerInfo(resolveInfoForSubselection(subselection)), + ).toEqual(expected); + }); + + it.each([ + ["profile not selected", "records { __typename }"], + ["profile without recognized sub-fields", "profile { __typename }"], + ])("returns null: %s", (_message, subselection) => { + expect( + buildProfileSelectionFromResolveContainerInfo(resolveInfoForSubselection(subselection)), + ).toBeNull(); + }); + + it("builds selection from inline fragments within profile selection", () => { + const doc = parse(` + fragment ProfileFields on DomainProfile { + description + avatar { httpUrl } + } + { resolve { profile { ...ProfileFields } } } + `); + const operation = doc.definitions.find((d) => d.kind === "OperationDefinition"); + if (!operation || operation.kind !== "OperationDefinition") throw new Error(); + const fragments: Record = {}; + for (const def of doc.definitions) { + if (def.kind === "FragmentDefinition") { + fragments[def.name.value] = def; + } + } + const resolveField = operation.selectionSet.selections[0]; + if (!resolveField || resolveField.kind !== "Field") throw new Error(); + + const info: GraphQLResolveInfo = { + fieldNodes: [resolveField], + fragments, + returnType: DomainResolveType, + variableValues: {}, + } as unknown as GraphQLResolveInfo; + + expect(buildProfileSelectionFromResolveContainerInfo(info)).toEqual({ + texts: ["description", "avatar"], + }); + }); +}); diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/build-profile-selection.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/build-profile-selection.ts new file mode 100644 index 0000000000..b231917e90 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/build-profile-selection.ts @@ -0,0 +1,119 @@ +import type { FieldNode, GraphQLResolveInfo, SelectionSetNode } from "graphql"; + +import type { ResolverRecordsSelection } from "@ensnode/ensnode-sdk"; + +import { + collectNamedSubFieldNodes, + mergeRecordsSelections, +} from "@/omnigraph-api/lib/resolution/records-selection"; + +import { + ADDRESS_PARSERS, + ProfileAvatarParser, + ProfileDescriptionParser, + ProfileHeaderParser, + ProfileWebsiteParser, + SOCIAL_PARSERS, +} from "./parsers"; + +/** Collect all FieldNodes named `fieldName` within a set of parent FieldNodes. */ +function collectSubFieldNodes( + parentNodes: readonly FieldNode[], + fieldName: string, + info: GraphQLResolveInfo, +): FieldNode[] { + return parentNodes.flatMap((node) => { + if (!node.selectionSet) return []; + return collectNamedSubFieldNodes(node.selectionSet, fieldName, info); + }); +} + +/** Collect all direct child field names from an array of parent field nodes (flattening fragments). */ +function collectChildFieldNames( + nodes: readonly FieldNode[], + info: GraphQLResolveInfo, +): Set { + const names = new Set(); + for (const node of nodes) { + if (!node.selectionSet) continue; + for (const sel of flattenSelections(node.selectionSet, info)) { + names.add(sel.name.value); + } + } + return names; +} + +function flattenSelections(selectionSet: SelectionSetNode, info: GraphQLResolveInfo): FieldNode[] { + const fields: FieldNode[] = []; + for (const sel of selectionSet.selections) { + if (sel.kind === "Field") { + if (sel.name.value !== "__typename") fields.push(sel); + } else if (sel.kind === "InlineFragment") { + fields.push(...flattenSelections(sel.selectionSet, info)); + } else if (sel.kind === "FragmentSpread") { + const fragment = info.fragments[sel.name.value]; + if (fragment) fields.push(...flattenSelections(fragment.selectionSet, info)); + } + } + return fields; +} + +/** + * Builds the {@link ResolverRecordsSelection} required to satisfy the `profile { ... }` sub-field + * within a `Domain.resolve` or `PrimaryNameRecord.resolve` info object. + * + * Returns null when `profile` is not selected or has no resolvable sub-fields. + */ +export function buildProfileSelectionFromResolveContainerInfo( + info: GraphQLResolveInfo, +): ResolverRecordsSelection | null { + // 1. Find all `profile` field nodes within the resolve container's selections + const profileNodes = info.fieldNodes.flatMap((resolveField) => { + if (!resolveField.selectionSet) return []; + return collectNamedSubFieldNodes(resolveField.selectionSet, "profile", info); + }); + + if (profileNodes.length === 0) return null; + + let merged: ResolverRecordsSelection | null = null; + + // 2. Check for top-level profile fields (description, avatar, header, website) + const topLevelFields = collectChildFieldNames(profileNodes, info); + + if (topLevelFields.has("description")) { + merged = mergeRecordsSelections(merged, ProfileDescriptionParser.selection); + } + if (topLevelFields.has("avatar")) { + merged = mergeRecordsSelections(merged, ProfileAvatarParser.selection); + } + if (topLevelFields.has("header")) { + merged = mergeRecordsSelections(merged, ProfileHeaderParser.selection); + } + if (topLevelFields.has("website")) { + merged = mergeRecordsSelections(merged, ProfileWebsiteParser.selection); + } + + // 3. Walk socials sub-fields + const socialsNodes = collectSubFieldNodes(profileNodes, "socials", info); + if (socialsNodes.length > 0) { + const socialFields = collectChildFieldNames(socialsNodes, info); + for (const [fieldName, parser] of Object.entries(SOCIAL_PARSERS)) { + if (socialFields.has(fieldName)) { + merged = mergeRecordsSelections(merged, parser.selection); + } + } + } + + // 4. Walk addresses sub-fields + const addressesNodes = collectSubFieldNodes(profileNodes, "addresses", info); + if (addressesNodes.length > 0) { + const addressFields = collectChildFieldNames(addressesNodes, info); + for (const [fieldName, parser] of Object.entries(ADDRESS_PARSERS)) { + if (addressFields.has(fieldName)) { + merged = mergeRecordsSelections(merged, parser.selection); + } + } + } + + return merged; +} diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/addresses.test.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/addresses.test.ts new file mode 100644 index 0000000000..663f3a57fa --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/addresses.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; + +import { ADDRESS_PARSERS } from "./addresses"; +import { profileRecordsModel } from "./test-helpers"; + +describe("ADDRESS_PARSERS", () => { + it.each([ + [ + "ethereum", + 60, + "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", + "0xd8da6bf26964af9d7eed9e03e53415d37aa96045", + ], + [ + "base", + 2147492101, + "0xD8DA6BF26964AF9D7EED9E03E53415D37AA96045", + "0xd8da6bf26964af9d7eed9e03e53415d37aa96045", + ], + [ + "bitcoin", + 0, + "0x76a91462e907b15cbf27d5425399ebf6f0fb50ebb88f1888ac", + "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", + ], + [ + "solana", + 501, + "0x8a11e71b96cabbe3216e3153b09694f39fc85022cbc076f79846a3ab4d8c1991", + "AHy6YZA8BsHgQfVkk7MbwpAN94iyN7Nf1zN4nPqUN32Q", + ], + ] as const)("parses %s address", (field, coinType, raw, expected) => { + expect(ADDRESS_PARSERS[field].selection).toEqual({ addresses: [coinType] }); + expect(ADDRESS_PARSERS[field].parse(profileRecordsModel({}, { [coinType]: raw }))).toBe( + expected, + ); + }); + + it.each([ + ["record unset", undefined], + ["empty string", ""], + ["0x sentinel", "0x"], + ["non-hex value", "not-hex"], + ] as const)("returns null: %s (%s)", (_message, raw) => { + for (const [field, parser] of Object.entries(ADDRESS_PARSERS)) { + const coinType = parser.selection.addresses?.[0]; + if (coinType == null) throw new Error(`Coin type not found for parser ${field}`); + const model = raw === undefined ? {} : { [coinType]: raw }; + expect(parser.parse(profileRecordsModel({}, model))).toBeNull(); + } + }); +}); diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/addresses.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/addresses.ts new file mode 100644 index 0000000000..6086491303 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/addresses.ts @@ -0,0 +1,50 @@ +import { type CoinName, getCoderByCoinName } from "@ensdomains/address-encoder"; +import { hexToBytes } from "@ensdomains/address-encoder/utils"; +import { type BitcoinAddress, type SolanaAddress, toNormalizedAddress } from "enssdk"; +import { isHex } from "viem"; + +import type { ProfileFieldParser } from "./types"; + +const buildAddressParser = ( + coinName: CoinName, + format: (encoded: string) => T, +): ProfileFieldParser => { + const coder = getCoderByCoinName(coinName); + + return { + selection: { addresses: [coder.coinType] }, + parse: (records) => { + const raw = records.addresses?.[coder.coinType]; + if (raw == null || raw === "" || raw === "0x") return null; + if (!isHex(raw)) return null; + + try { + const bytes = hexToBytes(raw); + if (bytes.length === 0 || bytes.every((byte) => byte === 0)) return null; + + return format(coder.encode(bytes)); + } catch { + return null; + } + }, + }; +}; + +export const ProfileAddressEthereumParser = buildAddressParser("eth", toNormalizedAddress); +export const ProfileAddressBaseParser = buildAddressParser("base", toNormalizedAddress); +export const ProfileAddressBitcoinParser = buildAddressParser( + "btc", + (address) => address as BitcoinAddress, +); +export const ProfileAddressSolanaParser = buildAddressParser( + "sol", + (address) => address as SolanaAddress, +); + +/** All address parsers keyed by their GraphQL field name. */ +export const ADDRESS_PARSERS = { + ethereum: ProfileAddressEthereumParser, + base: ProfileAddressBaseParser, + bitcoin: ProfileAddressBitcoinParser, + solana: ProfileAddressSolanaParser, +} as const satisfies Record>; diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/images.test.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/images.test.ts new file mode 100644 index 0000000000..e073b48982 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/images.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from "vitest"; + +import { ProfileAvatarParser, ProfileHeaderParser } from "./images"; +import { profileRecordsModel } from "./test-helpers"; + +vi.mock("@/di", () => ({ + default: { + context: { + namespace: "sepolia", + }, + }, +})); + +describe("ProfileAvatarParser", () => { + it("has correct selection", () => { + expect(ProfileAvatarParser.selection).toEqual({ texts: ["avatar"] }); + }); + + it.each([ + [ + "direct https URL", + { avatar: "https://example.com/avatar.png" }, + { httpUrl: "https://example.com/avatar.png" }, + ], + [ + "direct http URL", + { avatar: "http://example.com/avatar.png" }, + { httpUrl: "http://example.com/avatar.png" }, + ], + [ + "metadata service fallback for ipfs URL", + { avatar: "ipfs://QmAvatar" }, + { httpUrl: "https://metadata.ens.domains/sepolia/avatar/test.eth" }, + ], + [ + "metadata service fallback for non-http raw value", + { avatar: "not-a-url" }, + { httpUrl: "https://metadata.ens.domains/sepolia/avatar/test.eth" }, + ], + ])("parses %s", (_message, texts, expected) => { + expect(ProfileAvatarParser.parse(profileRecordsModel(texts))).toEqual(expected); + }); + + it.each([ + ["record unset", {}], + ["empty string", { avatar: "" }], + ])("returns null: %s", (_message, texts) => { + expect(ProfileAvatarParser.parse(profileRecordsModel(texts))).toBeNull(); + }); +}); + +describe("ProfileHeaderParser", () => { + it("has correct selection", () => { + expect(ProfileHeaderParser.selection).toEqual({ texts: ["header"] }); + }); + + it.each([ + [ + "direct https URL", + { header: "https://example.com/header.png" }, + { httpUrl: "https://example.com/header.png" }, + ], + [ + "direct http URL", + { header: "http://example.com/header.png" }, + { httpUrl: "http://example.com/header.png" }, + ], + [ + "metadata service fallback for ipfs URL", + { header: "ipfs://QmHeader" }, + { httpUrl: "https://metadata.ens.domains/sepolia/header/test.eth" }, + ], + [ + "metadata service fallback for non-http raw value", + { header: "not-a-url" }, + { httpUrl: "https://metadata.ens.domains/sepolia/header/test.eth" }, + ], + ])("parses %s", (_message, texts, expected) => { + expect(ProfileHeaderParser.parse(profileRecordsModel(texts))).toEqual(expected); + }); + + it.each([ + ["record unset", {}], + ["empty string", { header: "" }], + ])("returns null: %s", (_message, texts) => { + expect(ProfileHeaderParser.parse(profileRecordsModel(texts))).toBeNull(); + }); +}); diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/images.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/images.ts new file mode 100644 index 0000000000..b148bbffdc --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/images.ts @@ -0,0 +1,69 @@ +import { type EnsMetadataImageRecord, getEnsMetadataServiceImageUrl } from "enssdk"; + +import di from "@/di"; +import type { ResolvedRecordsModel } from "@/omnigraph-api/lib/resolution/records-profile-model"; + +import type { ProfileFieldParser } from "./types"; + +export type ProfileImageResult = { + httpUrl: string | null; +}; + +function parseDirectImageHttpUrl(raw: string): string | null { + const trimmed = raw.trim(); + if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) { + return null; + } + + try { + return new URL(trimmed).href; + } catch { + return null; + } +} + +const buildImageParser = ( + record: EnsMetadataImageRecord, +): ProfileFieldParser => ({ + selection: { texts: [record] }, + parse: (records) => { + const raw = records.texts?.[record]; + if (raw == null || raw === "") return null; + + const httpUrl = + parseDirectImageHttpUrl(raw) ?? interpretProfileImageHttpUrl(records, raw, record); + + return { httpUrl }; + }, +}); + +export const ProfileAvatarParser: ProfileFieldParser = + buildImageParser("avatar"); +export const ProfileHeaderParser: ProfileFieldParser = + buildImageParser("header"); + +/** + * Derives an HTTP-compatible profile image URL from a resolved records model. + * + * Returns null when the raw record is unset or the ENS Metadata Service is unavailable for the + * current namespace. + */ +function interpretProfileImageHttpUrl( + model: ResolvedRecordsModel, + rawValue: string | null | undefined, + record: EnsMetadataImageRecord, +): string | null { + if (!rawValue) return null; + + return getEnsMetadataServiceImageUrl(model.id, di.context.namespace, record)?.href ?? null; +} + +/** Returns the raw website record when set; callers expose it as `httpUrl`. */ +export function interpretProfileWebsiteHttpUrl(rawValue: string | null | undefined): string | null { + return rawValue ?? null; +} + +export const profileImageHttpUrlDescription = (recordLabel: "avatar" | "header") => + `Provides a HTTP-compatible URL for fetching the ${recordLabel} image that can be safely referenced as an image in web browsers. ` + + `This is an abstraction over the "raw" ${recordLabel} record, which may reference non-HTTP compatible URLs or encodings including IPFS urls, CAIP-22 / CAIP-29 NFT References, and more edge cases that cannot be trivially referenced as an image in most web browsers. ` + + "Additional details here: https://docs.ens.domains/ensip/12"; diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/index.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/index.ts new file mode 100644 index 0000000000..75098d689b --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/index.ts @@ -0,0 +1,22 @@ +export { + ADDRESS_PARSERS, + ProfileAddressBaseParser, + ProfileAddressBitcoinParser, + ProfileAddressEthereumParser, + ProfileAddressSolanaParser, +} from "./addresses"; +export type { ProfileImageResult } from "./images"; +export { + interpretProfileWebsiteHttpUrl, + ProfileAvatarParser, + ProfileHeaderParser, + profileImageHttpUrlDescription, +} from "./images"; +export { + SOCIAL_PARSERS, + SocialGithubParser, + SocialTelegramParser, + SocialTwitterParser, +} from "./social"; +export { ProfileDescriptionParser, ProfileWebsiteParser } from "./texts"; +export type { ProfileFieldParser } from "./types"; diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/social.test.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/social.test.ts new file mode 100644 index 0000000000..299a0956aa --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/social.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from "vitest"; + +import { SocialGithubParser, SocialTelegramParser, SocialTwitterParser } from "./social"; +import { profileRecordsModel } from "./test-helpers"; + +describe("SocialGithubParser", () => { + it("has correct selection", () => { + expect(SocialGithubParser.selection).toEqual({ texts: ["com.github"] }); + }); + + it.each([ + [ + "bare handle", + "itslevchiks", + { handle: "itslevchiks", httpUrl: "https://github.com/itslevchiks" }, + ], + [ + "@ prefix", + "@itslevchiks", + { handle: "itslevchiks", httpUrl: "https://github.com/itslevchiks" }, + ], + [ + "https URL", + "https://github.com/itslevchiks", + { handle: "itslevchiks", httpUrl: "https://github.com/itslevchiks" }, + ], + [ + "http URL", + "http://github.com/itslevchiks", + { handle: "itslevchiks", httpUrl: "https://github.com/itslevchiks" }, + ], + [ + "hostname without scheme", + "github.com/itslevchiks", + { handle: "itslevchiks", httpUrl: "https://github.com/itslevchiks" }, + ], + [ + "www hostname", + "www.github.com/itslevchiks", + { handle: "itslevchiks", httpUrl: "https://github.com/itslevchiks" }, + ], + [ + "trailing slash", + "https://github.com/itslevchiks/", + { handle: "itslevchiks", httpUrl: "https://github.com/itslevchiks" }, + ], + [ + "query string", + "https://github.com/itslevchiks?tab=repos", + { handle: "itslevchiks", httpUrl: "https://github.com/itslevchiks?tab=repos" }, + ], + [ + "hash fragment", + "https://github.com/itslevchiks#readme", + { handle: "itslevchiks", httpUrl: "https://github.com/itslevchiks#readme" }, + ], + [ + "surrounding whitespace", + " itslevchiks ", + { handle: "itslevchiks", httpUrl: "https://github.com/itslevchiks" }, + ], + [ + "hyphen and underscore", + "My-Handle_99", + { handle: "My-Handle_99", httpUrl: "https://github.com/My-Handle_99" }, + ], + [ + "repo URL", + "https://github.com/itslevchiks/some-repo", + { handle: "itslevchiks/some-repo", httpUrl: "https://github.com/itslevchiks/some-repo" }, + ], + [ + "repo URL with query string", + "https://github.com/itslevchiks/some-repo#intro?tab=readme", + { + handle: "itslevchiks/some-repo", + httpUrl: "https://github.com/itslevchiks/some-repo#intro?tab=readme", + }, + ], + [ + "bare org/repo path", + "itslevchiks/some-repo", + { handle: "itslevchiks/some-repo", httpUrl: "https://github.com/itslevchiks/some-repo" }, + ], + ])("parses %s", (_message, input, expected) => { + expect(SocialGithubParser.parse(profileRecordsModel({ "com.github": input }))).toEqual( + expected, + ); + }); + + it.each([ + ["record unset", {}], + ["empty string", { "com.github": "" }], + ["whitespace only", { "com.github": " " }], + ["invalid handle characters", { "com.github": "invalid user name!" }], + ["foreign social URL", { "com.github": "https://twitter.com/itslevchiks" }], + ])("returns null: %s", (_message, texts) => { + expect(SocialGithubParser.parse(profileRecordsModel(texts))).toBeNull(); + }); +}); + +describe("SocialTwitterParser", () => { + it("has correct selection", () => { + expect(SocialTwitterParser.selection).toEqual({ texts: ["com.twitter"] }); + }); + + it.each([ + ["bare handle", "itslevchiks", { handle: "itslevchiks", httpUrl: "https://x.com/itslevchiks" }], + ["@ prefix", "@itslevchiks", { handle: "itslevchiks", httpUrl: "https://x.com/itslevchiks" }], + [ + "https x.com URL", + "https://x.com/itslevchiks", + { handle: "itslevchiks", httpUrl: "https://x.com/itslevchiks" }, + ], + [ + "http x.com URL", + "http://x.com/itslevchiks", + { handle: "itslevchiks", httpUrl: "https://x.com/itslevchiks" }, + ], + [ + "x.com without scheme", + "x.com/itslevchiks", + { handle: "itslevchiks", httpUrl: "https://x.com/itslevchiks" }, + ], + [ + "twitter.com hostname", + "www.twitter.com/itslevchiks", + { handle: "itslevchiks", httpUrl: "https://x.com/itslevchiks" }, + ], + [ + "trailing slash", + "twitter.com/itslevchiks/", + { handle: "itslevchiks", httpUrl: "https://x.com/itslevchiks" }, + ], + [ + "query string", + "twitter.com/itslevchiks?lang=en", + { handle: "itslevchiks", httpUrl: "https://x.com/itslevchiks?lang=en" }, + ], + ])("parses %s", (_message, input, expected) => { + expect(SocialTwitterParser.parse(profileRecordsModel({ "com.twitter": input }))).toEqual( + expected, + ); + }); + + it.each([ + ["record unset", {}], + ["empty string", { "com.twitter": "" }], + ["invalid handle characters", { "com.twitter": "hello world" }], + ["foreign social URL", { "com.twitter": "https://github.com/itslevchiks" }], + ])("returns null: %s", (_message, texts) => { + expect(SocialTwitterParser.parse(profileRecordsModel(texts))).toBeNull(); + }); +}); + +describe("SocialTelegramParser", () => { + it("has correct selection", () => { + expect(SocialTelegramParser.selection).toEqual({ texts: ["org.telegram"] }); + }); + + it.each([ + ["bare handle", "itslevchiks", { handle: "itslevchiks", httpUrl: "https://t.me/itslevchiks" }], + ["@ prefix", "@itslevchiks", { handle: "itslevchiks", httpUrl: "https://t.me/itslevchiks" }], + [ + "https t.me URL", + "https://t.me/itslevchiks", + { handle: "itslevchiks", httpUrl: "https://t.me/itslevchiks" }, + ], + [ + "http t.me URL", + "http://t.me/itslevchiks", + { handle: "itslevchiks", httpUrl: "https://t.me/itslevchiks" }, + ], + [ + "t.me without scheme", + "t.me/itslevchiks", + { handle: "itslevchiks", httpUrl: "https://t.me/itslevchiks" }, + ], + [ + "telegram.me hostname", + "telegram.me/itslevchiks", + { handle: "itslevchiks", httpUrl: "https://t.me/itslevchiks" }, + ], + [ + "trailing slash", + "t.me/itslevchiks/", + { handle: "itslevchiks", httpUrl: "https://t.me/itslevchiks" }, + ], + ])("parses %s", (_message, input, expected) => { + expect(SocialTelegramParser.parse(profileRecordsModel({ "org.telegram": input }))).toEqual( + expected, + ); + }); + + it.each([ + ["record unset", {}], + ["empty string", { "org.telegram": "" }], + ["invalid handle characters", { "org.telegram": "bad handle!" }], + ["foreign social URL", { "org.telegram": "https://twitter.com/itslevchiks" }], + ])("returns null: %s", (_message, texts) => { + expect(SocialTelegramParser.parse(profileRecordsModel(texts))).toBeNull(); + }); +}); diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/social.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/social.ts new file mode 100644 index 0000000000..cd5c7baec9 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/social.ts @@ -0,0 +1,117 @@ +import type { ProfileFieldParser } from "./types"; + +export type SocialHandleResult = { + handle: string; + httpUrl: string; +}; + +export type ParseSocialHandleOptions = { + /** Raw text record value to parse. */ + value: string | null | undefined; + /** Accepted URL hostnames (e.g. ["github.com", "www.github.com"]). */ + hostnames: readonly string[]; + /** Base URL used to build the canonical profile URL (e.g. "https://github.com"). */ + baseUrl: string; + /** Regex pattern the extracted handle must match. */ + handlePattern: RegExp; +}; + +/** + * Normalizes a social handle from a raw ENS text record value. + * + * Tolerates several input shapes: + * - Bare handle: `itslevchiks` + * - With leading @: `@itslevchiks` + * - Full URL: `https://github.com/itslevchiks`, `http://github.com/itslevchiks` + * - Repo URL (when `allowDeepPath`): `https://github.com/itslevchiks/my-repo` + * - URL without scheme: `github.com/itslevchiks` + * - Trailing slash, query strings, hash fragments are ignored + * + * Returns null when the value is missing, empty, unparseable, or the extracted + * handle does not pass the character-class validation. + */ +export function parseSocialHandle({ + value, + hostnames, + baseUrl, + handlePattern, +}: ParseSocialHandleOptions): SocialHandleResult | null { + const raw = value?.trim(); + if (!raw) return null; + + let handle: string | null = null; + let httpUrl: string | null = null; + + // Attempt to parse as URL — prepend https:// if it looks like a bare hostname/path + const toParse = raw.startsWith("http://") || raw.startsWith("https://") ? raw : `https://${raw}`; + try { + const url = new URL(toParse); + if (hostnames.includes(url.hostname)) { + const segments = url.pathname.split("/").filter((s) => s.length > 0); + handle = segments.join("/") ?? null; + + if (handle) { + const baseUrlParsed = new URL(baseUrl); + url.host = baseUrlParsed.host; + url.protocol = baseUrlParsed.protocol; + url.pathname = url.pathname.endsWith("/") ? url.pathname.slice(0, -1) : url.pathname; + httpUrl = url.toString(); + } + } + } catch { + // Not a valid URL — fall through to bare handle treatment + } + + // If URL parsing did not produce a handle, treat as a bare handle + if (handle === null) { + handle = raw.startsWith("@") ? raw.slice(1) : raw; + } + + if (!handle || !handlePattern.test(handle)) return null; + + return { handle, httpUrl: httpUrl ?? `${baseUrl}/${handle}` }; +} + +const socialParser = ( + textKey: string, + hostnames: readonly string[], + baseUrl: string, + handlePattern: RegExp, +): ProfileFieldParser => ({ + selection: { texts: [textKey] }, + parse: (records) => + parseSocialHandle({ + value: records.texts?.[textKey], + hostnames, + baseUrl, + handlePattern, + }), +}); + +export const SocialGithubParser: ProfileFieldParser = socialParser( + "com.github", + ["github.com", "www.github.com"], + "https://github.com", + /^[A-Za-z0-9_./-]+$/, +); + +export const SocialTwitterParser: ProfileFieldParser = socialParser( + "com.twitter", + ["twitter.com", "www.twitter.com", "x.com", "www.x.com"], + "https://x.com", + /^[A-Za-z0-9_]+$/, +); + +export const SocialTelegramParser: ProfileFieldParser = socialParser( + "org.telegram", + ["t.me", "telegram.me", "www.telegram.me", "www.t.me"], + "https://t.me", + /^[A-Za-z0-9_]+$/, +); + +/** All social parsers keyed by their GraphQL field name. */ +export const SOCIAL_PARSERS = { + github: SocialGithubParser, + twitter: SocialTwitterParser, + telegram: SocialTelegramParser, +} as const satisfies Record>; diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/test-helpers.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/test-helpers.ts new file mode 100644 index 0000000000..6d3a817f3d --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/test-helpers.ts @@ -0,0 +1,10 @@ +import type { ResolvedRecordsModel } from "@/omnigraph-api/lib/resolution/records-profile-model"; + +export const profileRecordsModel = ( + texts?: Record, + addresses?: Record, +): ResolvedRecordsModel => ({ + id: "test.eth" as ResolvedRecordsModel["id"], + texts: texts ?? {}, + addresses: addresses ?? {}, +}); diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/texts.test.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/texts.test.ts new file mode 100644 index 0000000000..0f5d63887e --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/texts.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; + +import { profileRecordsModel } from "./test-helpers"; +import { ProfileDescriptionParser, ProfileWebsiteParser } from "./texts"; + +describe("ProfileDescriptionParser", () => { + it("has correct selection", () => { + expect(ProfileDescriptionParser.selection).toEqual({ texts: ["description"] }); + }); + + it.each([ + ["plain text", { description: "Hello" }, "Hello"], + ["whitespace preserved", { description: " Hello " }, " Hello "], + ])("parses %s", (_message, texts, expected) => { + expect(ProfileDescriptionParser.parse(profileRecordsModel(texts))).toBe(expected); + }); + + it.each([ + ["record unset", {}], + ["empty string", { description: "" }], + ])("returns null: %s", (_message, texts) => { + expect(ProfileDescriptionParser.parse(profileRecordsModel(texts))).toBeNull(); + }); +}); + +describe("ProfileWebsiteParser", () => { + it("has correct selection", () => { + expect(ProfileWebsiteParser.selection).toEqual({ texts: ["url"] }); + }); + + it.each([ + ["https URL", { url: "https://example.com" }, "https://example.com"], + ["http URL", { url: "http://example.com" }, "http://example.com"], + ])("parses %s", (_message, texts, expected) => { + expect(ProfileWebsiteParser.parse(profileRecordsModel(texts))).toBe(expected); + }); + + it.each([ + ["record unset", {}], + ["empty string", { url: "" }], + ])("returns null: %s", (_message, texts) => { + expect(ProfileWebsiteParser.parse(profileRecordsModel(texts))).toBeNull(); + }); +}); diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/texts.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/texts.ts new file mode 100644 index 0000000000..284aa38611 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/texts.ts @@ -0,0 +1,13 @@ +import type { ProfileFieldParser } from "./types"; + +const textParser = (key: string): ProfileFieldParser => ({ + selection: { texts: [key] }, + parse: (records) => { + const raw = records.texts?.[key]; + if (raw == null || raw === "") return null; + return raw; + }, +}); + +export const ProfileDescriptionParser: ProfileFieldParser = textParser("description"); +export const ProfileWebsiteParser: ProfileFieldParser = textParser("url"); diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/types.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/types.ts new file mode 100644 index 0000000000..6bb9a52414 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/types.ts @@ -0,0 +1,16 @@ +import type { ResolverRecordsSelection } from "@ensnode/ensnode-sdk"; + +import type { ResolvedRecordsModel } from "@/omnigraph-api/lib/resolution/records-profile-model"; + +/** + * Declares which records a profile field needs and how to derive its GraphQL output from them. + * + * Each profile field is implemented as a singleton `ProfileFieldParser`. The parent resolver + * passes the shared `ResolvedRecordsModel` to `parse`, keeping all resolution in one round-trip. + */ +export interface ProfileFieldParser { + /** The record keys this parser requires. Merged into the parent selection before resolution. */ + selection: ResolverRecordsSelection; + /** Derive the GraphQL output from the resolved records, or null if the record is unset. */ + parse(records: ResolvedRecordsModel): TOutput | null; +} diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.test.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.test.ts index 552743d946..bcb3c93745 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.test.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.test.ts @@ -15,6 +15,7 @@ import { buildRecordsSelectionFromResolveContainerInfo, buildRecordsSelectionFromResolveInfo, EMPTY_RECORDS_SELECTION_MESSAGE, + mergeRecordsSelections, } from "@/omnigraph-api/lib/resolution/records-selection"; import { RECORDS_SELECTION_PARAMETRIC_FIELDS, @@ -235,3 +236,35 @@ describe("buildRecordsSelectionFromResolveContainerInfo", () => { expect(buildRecordsSelectionFromResolveContainerInfo(info)).toBeNull(); }); }); + +describe("mergeRecordsSelections", () => { + it("returns null when both inputs are null", () => { + expect(mergeRecordsSelections(null, null)).toBeNull(); + }); + + it("returns the non-null input when one side is null", () => { + const selection = { texts: ["description"] }; + expect(mergeRecordsSelections(selection, null)).toEqual(selection); + expect(mergeRecordsSelections(null, selection)).toEqual(selection); + }); + + it("unions texts and addresses and ORs abi bitmasks", () => { + expect(mergeRecordsSelections({ addresses: [] }, { addresses: [0] })).toEqual({ + addresses: [0], + }); + expect( + mergeRecordsSelections( + { texts: ["avatar"], addresses: [] }, + { texts: ["description"], addresses: [60] }, + ), + ).toEqual({ + texts: ["avatar", "description"], + addresses: [60], + }); + + expect(mergeRecordsSelections({ abi: 1n }, { abi: 2n })).toEqual({ abi: 3n }); + expect(mergeRecordsSelections({ addresses: [60] }, { addresses: [61] })).toEqual({ + addresses: [60, 61], + }); + }); +}); diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.ts index 343678c162..55c5b53aa3 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.ts @@ -128,6 +128,38 @@ function buildRecordsSelectionFromRecordsFieldNodes( return recordsSelection; } +/** + * Merges two nullable {@link ResolverRecordsSelection} objects into one. + * + * - `texts` and `addresses` arrays are unioned (duplicates preserved; callers should deduplicate + * if needed, but the resolution layer handles duplicates gracefully). + * - `abi` content-type bitmasks are OR-ed so that all requested content types are fetched. + * - Boolean flags are OR-ed. + * - Returns null only when both inputs are null. + */ +export function mergeRecordsSelections( + a: ResolverRecordsSelection | null, + b: ResolverRecordsSelection | null, +): ResolverRecordsSelection | null { + if (!a && !b) return null; + if (!a) return b; + if (!b) return a; + + return { + name: a.name || b.name || undefined, + texts: a.texts || b.texts ? [...(a.texts ?? []), ...(b.texts ?? [])] : undefined, + addresses: + a.addresses || b.addresses ? [...(a.addresses ?? []), ...(b.addresses ?? [])] : undefined, + contenthash: a.contenthash || b.contenthash || undefined, + pubkey: a.pubkey || b.pubkey || undefined, + abi: a.abi !== undefined || b.abi !== undefined ? (a.abi ?? 0n) | (b.abi ?? 0n) : undefined, + interfaces: + a.interfaces || b.interfaces ? [...(a.interfaces ?? []), ...(b.interfaces ?? [])] : undefined, + dnszonehash: a.dnszonehash || b.dnszonehash || undefined, + version: a.version || b.version || undefined, + }; +} + /** * Builds a {@link ResolverRecordsSelection} from the GraphQL field selection on `Domain.records`. * diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts b/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts index 4e4a2ffd27..26771fa5c1 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts @@ -23,7 +23,6 @@ import { DatasourceNames } from "@ensnode/datasources"; import { accounts, addresses, fixtures } from "@ensnode/datasources/devnet"; import { getDatasourceContract } from "@ensnode/ensnode-sdk"; -import { INCLUDE_DEV_METHODS } from "@/omnigraph-api/lib/include-dev-methods"; import { DEVNET_ETH_LABELS, DEVNET_NAMES } from "@/test/integration/devnet-names"; import { DomainSubdomainsPaginated, @@ -683,16 +682,15 @@ describe("Domain.records", () => { }); }); -(INCLUDE_DEV_METHODS ? describe : describe.skip)("Domain.profile", () => { +describe("Domain.profile", () => { type DomainProfileResult = { domain: { resolve: { profile: { description: string | null; - avatar: { url: string | null } | null; - // ethereum address is a checksummed EVM address, so NormalizedAddress is the narrowed type + avatar: { httpUrl: string | null } | null; addresses: { ethereum: NormalizedAddress | null } | null; - socials: { github: { handle: string | null; url: string | null } | null } | null; + socials: { github: { handle: string; httpUrl: string } | null } | null; } | null; }; }; @@ -704,29 +702,54 @@ describe("Domain.records", () => { resolve { profile { description - avatar { url } + avatar { httpUrl } addresses { ethereum } - socials { github { handle url } } + socials { github { handle httpUrl } } } } } } `; - it("returns the preview null shape for a canonical domain", async () => { + it("interprets profile fields for test.eth", async () => { await expect( request(DomainProfile, { name: "test.eth" }), - ).resolves.toEqual({ + ).resolves.toMatchObject({ domain: { resolve: { profile: { - description: null, - avatar: { url: null }, - addresses: { ethereum: null }, - socials: { github: { handle: null, url: null } }, + description: "test.eth", + avatar: { httpUrl: "https://example.com/avatar.png" }, + addresses: { ethereum: accounts.owner.address }, + socials: { github: { handle: "ensdomains", httpUrl: "https://github.com/ensdomains" } }, }, }, }, }); }); + + it("returns null when profile is not selected for resolution", async () => { + const DomainResolveWithoutProfile = gql` + query DomainResolveWithoutProfile($name: InterpretedName!) { + domain(by: { name: $name }) { + resolve { + acceleration { requested attempted } + } + } + } + `; + + await expect( + request<{ domain: { resolve: { acceleration: { requested: boolean } } } }>( + DomainResolveWithoutProfile, + { name: "test.eth" }, + ), + ).resolves.toMatchObject({ + domain: { + resolve: { + acceleration: { requested: true }, + }, + }, + }); + }); }); diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.ts b/apps/ensapi/src/omnigraph-api/schema/domain.ts index 43a175e1e2..b2e8160c18 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.ts @@ -22,8 +22,12 @@ import { resolveFindEvents } from "@/omnigraph-api/lib/find-events/find-events-r import { getLatestRegistration } from "@/omnigraph-api/lib/get-latest-registration"; import { getModelId } from "@/omnigraph-api/lib/get-model-id"; import { lazyConnection } from "@/omnigraph-api/lib/lazy-connection"; +import { buildProfileSelectionFromResolveContainerInfo } from "@/omnigraph-api/lib/resolution/profile/build-profile-selection"; import { toResolvedRecordsModel } from "@/omnigraph-api/lib/resolution/records-profile-model"; -import { buildRecordsSelectionFromResolveContainerInfo } from "@/omnigraph-api/lib/resolution/records-selection"; +import { + buildRecordsSelectionFromResolveContainerInfo, + mergeRecordsSelections, +} from "@/omnigraph-api/lib/resolution/records-selection"; import { AccountRef } from "@/omnigraph-api/schema/account"; import { ID_PAGINATED_CONNECTION_ARGS, @@ -200,13 +204,20 @@ DomainInterfaceRef.implement({ return { accelerate, canAccelerate, trace: null, records: null }; } - const recordsSelection = buildRecordsSelectionFromResolveContainerInfo(info); - if (!recordsSelection) { + const mergedSelection = + name && isNormalizedName(name) + ? mergeRecordsSelections( + buildRecordsSelectionFromResolveContainerInfo(info), + buildProfileSelectionFromResolveContainerInfo(info), + ) + : null; + + if (!mergedSelection) { return { accelerate, canAccelerate, trace: null, records: null }; } const { trace, result } = await runWithTrace(() => - resolveForward(name, recordsSelection, { accelerate, canAccelerate }), + resolveForward(name, mergedSelection, { accelerate, canAccelerate }), ); return { diff --git a/apps/ensapi/src/omnigraph-api/schema/forward-resolve.ts b/apps/ensapi/src/omnigraph-api/schema/forward-resolve.ts index 37f97944f6..cf4f0c5665 100644 --- a/apps/ensapi/src/omnigraph-api/schema/forward-resolve.ts +++ b/apps/ensapi/src/omnigraph-api/schema/forward-resolve.ts @@ -3,7 +3,6 @@ import type { JsonValue } from "enssdk"; import type { TracingTrace } from "@ensnode/ensnode-sdk"; import { builder } from "@/omnigraph-api/builder"; -import { INCLUDE_DEV_METHODS } from "@/omnigraph-api/lib/include-dev-methods"; import type { ResolvedRecordsModel } from "@/omnigraph-api/lib/resolution/records-profile-model"; import { DomainProfileRef } from "@/omnigraph-api/schema/profile"; import { ResolvedRecordsRef } from "@/omnigraph-api/schema/records"; @@ -45,14 +44,12 @@ ForwardResolveRef.implement({ tracing: true, resolve: (parent) => parent.records, }), - ...(INCLUDE_DEV_METHODS && { - profile: t.field({ - description: - "PREVIEW: An interpreted ENS profile for this Domain. Types are defined for query ergonomics; resolution is not yet wired. Returns null when the domain is not canonical or normalized.", - type: DomainProfileRef, - nullable: true, - resolve: (parent) => (parent.records ? {} : null), - }), + profile: t.field({ + description: + "An interpreted ENS profile for this Domain. Returns null when the name is not resolvable (non-canonical, unnormalized, or no profile field was selected).", + type: DomainProfileRef, + nullable: true, + resolve: (parent) => parent.records, }), }), }); diff --git a/apps/ensapi/src/omnigraph-api/schema/primary-name-record.ts b/apps/ensapi/src/omnigraph-api/schema/primary-name-record.ts index 1148c42791..2ce786a70f 100644 --- a/apps/ensapi/src/omnigraph-api/schema/primary-name-record.ts +++ b/apps/ensapi/src/omnigraph-api/schema/primary-name-record.ts @@ -4,8 +4,12 @@ import { resolveForward } from "@/lib/resolution/forward-resolution"; import { runWithTrace } from "@/lib/tracing/tracing-api"; import { builder } from "@/omnigraph-api/builder"; import type { ChainNameValue } from "@/omnigraph-api/lib/resolution/chain-coin-type"; +import { buildProfileSelectionFromResolveContainerInfo } from "@/omnigraph-api/lib/resolution/profile/build-profile-selection"; import { toResolvedRecordsModel } from "@/omnigraph-api/lib/resolution/records-profile-model"; -import { buildRecordsSelectionFromResolveContainerInfo } from "@/omnigraph-api/lib/resolution/records-selection"; +import { + buildRecordsSelectionFromResolveContainerInfo, + mergeRecordsSelections, +} from "@/omnigraph-api/lib/resolution/records-selection"; import { CanonicalNameRef } from "@/omnigraph-api/schema/canonical-name"; import { type ForwardResolveModel, @@ -62,13 +66,20 @@ PrimaryNameRecordRef.implement({ return { accelerate, canAccelerate, trace: null, records: null }; } - const recordsSelection = buildRecordsSelectionFromResolveContainerInfo(info); - if (!recordsSelection) { + const mergedSelection = + name && isNormalizedName(name) + ? mergeRecordsSelections( + buildRecordsSelectionFromResolveContainerInfo(info), + buildProfileSelectionFromResolveContainerInfo(info), + ) + : null; + + if (!mergedSelection) { return { accelerate, canAccelerate, trace: null, records: null }; } const { trace, result } = await runWithTrace(() => - resolveForward(name, recordsSelection, { accelerate, canAccelerate }), + resolveForward(name, mergedSelection, { accelerate, canAccelerate }), ); return { diff --git a/apps/ensapi/src/omnigraph-api/schema/profile.ts b/apps/ensapi/src/omnigraph-api/schema/profile.ts index 1803adf379..fad8609fd3 100644 --- a/apps/ensapi/src/omnigraph-api/schema/profile.ts +++ b/apps/ensapi/src/omnigraph-api/schema/profile.ts @@ -1,153 +1,162 @@ import { builder } from "@/omnigraph-api/builder"; +import { + ADDRESS_PARSERS, + interpretProfileWebsiteHttpUrl, + ProfileAvatarParser, + ProfileDescriptionParser, + ProfileHeaderParser, + ProfileWebsiteParser, + profileImageHttpUrlDescription, + SOCIAL_PARSERS, +} from "@/omnigraph-api/lib/resolution/profile/parsers"; +import type { ResolvedRecordsModel } from "@/omnigraph-api/lib/resolution/records-profile-model"; -type ProfileSectionModel = Record; +export type ProfileSocialAccountModel = { handle: string; httpUrl: string }; +export type ProfileImageModel = { httpUrl: string | null }; export const ProfileSocialAccountRef = - builder.objectRef("ProfileSocialAccount"); + builder.objectRef("ProfileSocialAccount"); ProfileSocialAccountRef.implement({ - description: "PREVIEW: An interpreted social account on a Domain profile. Not yet resolved.", + description: "An interpreted social account on a Domain profile.", fields: (t) => ({ - handle: t.string({ - description: "The social handle, or null when unset.", - nullable: true, - resolve: () => null, + handle: t.exposeString("handle", { + description: "The social handle.", + nullable: false, }), - url: t.string({ - description: "The social profile URL, or null when unset.", - nullable: true, - resolve: () => null, + httpUrl: t.exposeString("httpUrl", { + description: "The HTTP-compatible social profile URL.", + nullable: false, }), }), }); -export const ProfileSocialsRef = builder.objectRef("ProfileSocials"); +export const ProfileSocialsRef = builder.objectRef("ProfileSocials"); ProfileSocialsRef.implement({ - description: "PREVIEW: Interpreted social accounts on a Domain profile. Not yet resolved.", + description: "Interpreted social accounts on a Domain profile.", fields: (t) => ({ github: t.field({ type: ProfileSocialAccountRef, nullable: true, - resolve: () => ({}), + resolve: (model) => SOCIAL_PARSERS.github.parse(model), }), telegram: t.field({ type: ProfileSocialAccountRef, nullable: true, - resolve: () => ({}), + resolve: (model) => SOCIAL_PARSERS.telegram.parse(model), }), twitter: t.field({ type: ProfileSocialAccountRef, nullable: true, - resolve: () => ({}), + resolve: (model) => SOCIAL_PARSERS.twitter.parse(model), }), }), }); -export const ProfileAddressesRef = builder.objectRef("ProfileAddresses"); +export const ProfileAddressesRef = builder.objectRef("ProfileAddresses"); ProfileAddressesRef.implement({ - description: "PREVIEW: Interpreted address records on a Domain profile. Not yet resolved.", + description: "Interpreted address records on a Domain profile.", fields: (t) => ({ ethereum: t.field({ description: "The interpreted Ethereum address, or null when unset.", type: "Address", nullable: true, - resolve: () => null, + resolve: (model) => ADDRESS_PARSERS.ethereum.parse(model), }), base: t.field({ description: "The interpreted Base address, or null when unset.", type: "Address", nullable: true, - resolve: () => null, + resolve: (model) => ADDRESS_PARSERS.base.parse(model), }), - bitcoin: t.string({ + bitcoin: t.field({ description: "The interpreted Bitcoin address, or null when unset.", + type: "BitcoinAddress", nullable: true, - resolve: () => null, + resolve: (model) => ADDRESS_PARSERS.bitcoin.parse(model), }), - solana: t.string({ + solana: t.field({ description: "The interpreted Solana address, or null when unset.", + type: "SolanaAddress", nullable: true, - resolve: () => null, + resolve: (model) => ADDRESS_PARSERS.solana.parse(model), }), }), }); -export const ProfileAvatarRef = builder.objectRef("ProfileAvatar"); +export const ProfileAvatarRef = builder.objectRef("ProfileAvatar"); ProfileAvatarRef.implement({ - description: "PREVIEW: Interpreted avatar metadata on a Domain profile. Not yet resolved.", + description: "Interpreted avatar metadata on a Domain profile.", fields: (t) => ({ - url: t.string({ - description: "The resolved avatar URL, or null when unset.", + httpUrl: t.exposeString("httpUrl", { + description: profileImageHttpUrlDescription("avatar"), nullable: true, - resolve: () => null, }), }), }); -export const ProfileBannerRef = builder.objectRef("ProfileBanner"); +export const ProfileHeaderRef = builder.objectRef("ProfileHeader"); -ProfileBannerRef.implement({ - description: "PREVIEW: Interpreted banner metadata on a Domain profile. Not yet resolved.", +ProfileHeaderRef.implement({ + description: "Interpreted header metadata on a Domain profile.", fields: (t) => ({ - url: t.string({ - description: "The resolved banner URL, or null when unset.", + httpUrl: t.exposeString("httpUrl", { + description: profileImageHttpUrlDescription("header"), nullable: true, - resolve: () => null, }), }), }); -export const ProfileWebsiteRef = builder.objectRef("ProfileWebsite"); +export const ProfileWebsiteRef = builder.objectRef("ProfileWebsite"); ProfileWebsiteRef.implement({ - description: "PREVIEW: Interpreted website metadata on a Domain profile. Not yet resolved.", + description: "Interpreted website metadata on a Domain profile.", fields: (t) => ({ - url: t.string({ - description: "The resolved website URL, or null when unset.", + httpUrl: t.string({ + description: "The HTTP-compatible website URL, or null when unset.", nullable: true, - resolve: () => null, + resolve: (model) => interpretProfileWebsiteHttpUrl(ProfileWebsiteParser.parse(model)), }), }), }); -export const DomainProfileRef = builder.objectRef("DomainProfile"); +export const DomainProfileRef = builder.objectRef("DomainProfile"); DomainProfileRef.implement({ - description: - "PREVIEW: An interpreted ENS profile for a name. Types are defined for query ergonomics; resolution is not yet wired.", + description: "An interpreted ENS profile for a name.", fields: (t) => ({ avatar: t.field({ type: ProfileAvatarRef, nullable: true, - resolve: () => ({}), + resolve: (model) => ProfileAvatarParser.parse(model), }), - banner: t.field({ - type: ProfileBannerRef, + header: t.field({ + type: ProfileHeaderRef, nullable: true, - resolve: () => ({}), + resolve: (model) => ProfileHeaderParser.parse(model), }), website: t.field({ type: ProfileWebsiteRef, nullable: true, - resolve: () => ({}), + resolve: (model) => (ProfileWebsiteParser.parse(model) ? model : null), }), description: t.string({ description: "The profile description, or null when unset.", nullable: true, - resolve: () => null, + resolve: (model) => ProfileDescriptionParser.parse(model), }), addresses: t.field({ type: ProfileAddressesRef, nullable: true, - resolve: () => ({}), + resolve: (model) => model, }), socials: t.field({ type: ProfileSocialsRef, nullable: true, - resolve: () => ({}), + resolve: (model) => model, }), }), }); diff --git a/apps/ensapi/src/omnigraph-api/schema/scalars.ts b/apps/ensapi/src/omnigraph-api/schema/scalars.ts index 2b41126c32..90825b519c 100644 --- a/apps/ensapi/src/omnigraph-api/schema/scalars.ts +++ b/apps/ensapi/src/omnigraph-api/schema/scalars.ts @@ -1,6 +1,8 @@ +import { getCoderByCoinType } from "@ensdomains/address-encoder"; import { type BeautifiedLabel, type BeautifiedName, + type BitcoinAddress, type ChainId, type CoinType, type DomainId, @@ -23,6 +25,7 @@ import { type RenewalId, type ResolverId, type ResolverRecordsId, + type SolanaAddress, } from "enssdk"; import { isHex, size } from "viem"; import { z } from "zod/v4"; @@ -53,6 +56,31 @@ builder.scalarType("Address", { parseValue: (value) => makeNormalizedAddressSchema("Address").parse(value), }); +const makeCoinAddressSchema = (label: string, coinType: number) => + z.coerce.string().check((ctx) => { + try { + getCoderByCoinType(coinType).decode(ctx.value); + } catch { + ctx.issues.push({ + code: "custom", + message: `Must be a valid ${label} address`, + input: ctx.value, + }); + } + }); + +builder.scalarType("BitcoinAddress", { + description: "BitcoinAddress represents a Base58Check-encoded Bitcoin address (coin type 0).", + serialize: (value: BitcoinAddress) => value, + parseValue: (value) => makeCoinAddressSchema("Bitcoin", 0).parse(value) as BitcoinAddress, +}); + +builder.scalarType("SolanaAddress", { + description: "SolanaAddress represents a Base58-encoded Solana address (coin type 501).", + serialize: (value: SolanaAddress) => value, + parseValue: (value) => makeCoinAddressSchema("Solana", 501).parse(value) as SolanaAddress, +}); + builder.scalarType("Hex", { description: "Hex represents viem#Hex.", serialize: (value: Hex) => value, diff --git a/packages/enskit/src/react/omnigraph/_lib/cache-exchange.ts b/packages/enskit/src/react/omnigraph/_lib/cache-exchange.ts index 665f56c688..fbf5e4851f 100644 --- a/packages/enskit/src/react/omnigraph/_lib/cache-exchange.ts +++ b/packages/enskit/src/react/omnigraph/_lib/cache-exchange.ts @@ -57,7 +57,7 @@ export const omnigraphCacheExchange = cacheExchange({ // dont forget to add cache strategy when DomainProfile is wired DomainProfile: EMBEDDED_DATA, ProfileAvatar: EMBEDDED_DATA, - ProfileBanner: EMBEDDED_DATA, + ProfileHeader: EMBEDDED_DATA, ProfileWebsite: EMBEDDED_DATA, ProfileAddresses: EMBEDDED_DATA, ProfileSocials: EMBEDDED_DATA, diff --git a/packages/enssdk/src/lib/ens-metadata-service.test.ts b/packages/enssdk/src/lib/ens-metadata-service.test.ts new file mode 100644 index 0000000000..9e703c9cf0 --- /dev/null +++ b/packages/enssdk/src/lib/ens-metadata-service.test.ts @@ -0,0 +1,31 @@ +import { asInterpretedName } from "enssdk"; +import { describe, expect, it } from "vitest"; + +import { + getEnsMetadataServiceAvatarUrl, + getEnsMetadataServiceImageUrl, +} from "./ens-metadata-service"; + +describe("getEnsMetadataServiceImageUrl", () => { + const name = asInterpretedName("vitalik.eth"); + + it("returns a sepolia header URL", () => { + expect(getEnsMetadataServiceImageUrl(name, "sepolia", "header")?.href).toBe( + "https://metadata.ens.domains/sepolia/header/vitalik.eth", + ); + }); + + it("returns null for unsupported namespaces", () => { + expect(getEnsMetadataServiceImageUrl(name, "ens-test-env", "avatar")).toBeNull(); + expect(getEnsMetadataServiceImageUrl(name, "sepolia-v2", "avatar")).toBeNull(); + }); +}); + +describe("getEnsMetadataServiceAvatarUrl", () => { + it("delegates to the avatar image endpoint", () => { + const name = asInterpretedName("test.eth"); + expect(getEnsMetadataServiceAvatarUrl(name, "mainnet")?.href).toBe( + "https://metadata.ens.domains/mainnet/avatar/test.eth", + ); + }); +}); diff --git a/packages/enssdk/src/lib/ens-metadata-service.ts b/packages/enssdk/src/lib/ens-metadata-service.ts new file mode 100644 index 0000000000..dbeeffdc46 --- /dev/null +++ b/packages/enssdk/src/lib/ens-metadata-service.ts @@ -0,0 +1,53 @@ +import type { Name } from "./types/ens"; + +/** ENS text record types supported by the ENS Metadata Service image endpoints. */ +export type EnsMetadataImageRecord = "avatar" | "header"; + +const METADATA_NETWORKS = { + mainnet: "mainnet", + sepolia: "sepolia", +} as const; + +type MetadataNetwork = (typeof METADATA_NETWORKS)[keyof typeof METADATA_NETWORKS]; + +function namespaceIdToMetadataNetwork(namespaceId: string): MetadataNetwork | null { + switch (namespaceId) { + case "mainnet": + return METADATA_NETWORKS.mainnet; + case "sepolia": + return METADATA_NETWORKS.sepolia; + default: + return null; + } +} + +/** + * Build an HTTP-compatible image URL for a name on the given ENS namespace that (once fetched) + * loads the requested profile image record from the ENS Metadata Service + * (https://metadata.ens.domains/docs). + * + * The returned URL is dynamically built based on the provided ENS namespace. Not all ENS + * namespaces are supported by the ENS Metadata Service. Therefore, the returned URL may be null. + */ +export function getEnsMetadataServiceImageUrl( + name: Name, + namespaceId: string, + record: EnsMetadataImageRecord, +): URL | null { + const network = namespaceIdToMetadataNetwork(namespaceId); + if (!network) return null; + + return new URL(name, `https://metadata.ens.domains/${network}/${record}/`); +} + +/** + * Build the avatar image URL for a name on the given ENS namespace that (once fetched) would + * load the avatar image for the given name from the ENS Metadata Service + * (https://metadata.ens.domains/docs). + * + * The returned URL is dynamically built based on the provided ENS namespace. Not all ENS + * namespaces are supported by the ENS Metadata Service. Therefore, the returned URL may be null. + */ +export function getEnsMetadataServiceAvatarUrl(name: Name, namespaceId: string): URL | null { + return getEnsMetadataServiceImageUrl(name, namespaceId, "avatar"); +} diff --git a/packages/enssdk/src/lib/index.ts b/packages/enssdk/src/lib/index.ts index 4cdc7b5ebb..f48994631e 100644 --- a/packages/enssdk/src/lib/index.ts +++ b/packages/enssdk/src/lib/index.ts @@ -4,6 +4,7 @@ export * from "./caip"; export * from "./coin-type"; export * from "./constants"; export * from "./dns-encoded-name"; +export * from "./ens-metadata-service"; export * from "./ids"; export * from "./interface-id"; export * from "./interpret-token-id"; diff --git a/packages/enssdk/src/lib/types/addresses.ts b/packages/enssdk/src/lib/types/addresses.ts new file mode 100644 index 0000000000..2cb3e1a086 --- /dev/null +++ b/packages/enssdk/src/lib/types/addresses.ts @@ -0,0 +1,9 @@ +/** + * Base58Check-encoded Bitcoin address (SLIP-44 coin type 0). + */ +export type BitcoinAddress = string; + +/** + * Base58-encoded Solana address (SLIP-44 coin type 501). + */ +export type SolanaAddress = string; diff --git a/packages/enssdk/src/lib/types/index.ts b/packages/enssdk/src/lib/types/index.ts index 220c1e2639..9a9cb7015f 100644 --- a/packages/enssdk/src/lib/types/index.ts +++ b/packages/enssdk/src/lib/types/index.ts @@ -1,3 +1,4 @@ +export * from "./addresses"; export * from "./coin-type"; export * from "./eac"; export * from "./ens"; diff --git a/packages/enssdk/src/omnigraph/generated/introspection.ts b/packages/enssdk/src/omnigraph/generated/introspection.ts index b51975a7d0..87bd3a9049 100644 --- a/packages/enssdk/src/omnigraph/generated/introspection.ts +++ b/packages/enssdk/src/omnigraph/generated/introspection.ts @@ -1093,6 +1093,10 @@ const introspection = { "kind": "SCALAR", "name": "BigInt" }, + { + "kind": "SCALAR", + "name": "BitcoinAddress" + }, { "kind": "SCALAR", "name": "Boolean" @@ -1666,19 +1670,19 @@ const introspection = { "isDeprecated": false }, { - "name": "banner", + "name": "description", "type": { - "kind": "OBJECT", - "name": "ProfileBanner" + "kind": "SCALAR", + "name": "String" }, "args": [], "isDeprecated": false }, { - "name": "description", + "name": "header", "type": { - "kind": "SCALAR", - "name": "String" + "kind": "OBJECT", + "name": "ProfileHeader" }, "args": [], "isDeprecated": false @@ -3793,6 +3797,15 @@ const introspection = { "args": [], "isDeprecated": false }, + { + "name": "profile", + "type": { + "kind": "OBJECT", + "name": "DomainProfile" + }, + "args": [], + "isDeprecated": false + }, { "name": "records", "type": { @@ -4947,7 +4960,7 @@ const introspection = { "name": "bitcoin", "type": { "kind": "SCALAR", - "name": "String" + "name": "BitcoinAddress" }, "args": [], "isDeprecated": false @@ -4965,7 +4978,7 @@ const introspection = { "name": "solana", "type": { "kind": "SCALAR", - "name": "String" + "name": "SolanaAddress" }, "args": [], "isDeprecated": false @@ -4978,7 +4991,7 @@ const introspection = { "name": "ProfileAvatar", "fields": [ { - "name": "url", + "name": "httpUrl", "type": { "kind": "SCALAR", "name": "String" @@ -4991,10 +5004,10 @@ const introspection = { }, { "kind": "OBJECT", - "name": "ProfileBanner", + "name": "ProfileHeader", "fields": [ { - "name": "url", + "name": "httpUrl", "type": { "kind": "SCALAR", "name": "String" @@ -5012,17 +5025,23 @@ const introspection = { { "name": "handle", "type": { - "kind": "SCALAR", - "name": "String" + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "String" + } }, "args": [], "isDeprecated": false }, { - "name": "url", + "name": "httpUrl", "type": { - "kind": "SCALAR", - "name": "String" + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "String" + } }, "args": [], "isDeprecated": false @@ -5069,7 +5088,7 @@ const introspection = { "name": "ProfileWebsite", "fields": [ { - "name": "url", + "name": "httpUrl", "type": { "kind": "SCALAR", "name": "String" @@ -6991,6 +7010,10 @@ const introspection = { ], "interfaces": [] }, + { + "kind": "SCALAR", + "name": "SolanaAddress" + }, { "kind": "SCALAR", "name": "String" diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index 27d0a5a62d..3398130388 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -237,6 +237,11 @@ scalar BeautifiedName """BigInt represents non-fractional signed whole numeric values.""" scalar BigInt +""" +BitcoinAddress represents a Base58Check-encoded Bitcoin address (coin type 0). +""" +scalar BitcoinAddress + """A Canonical Name, exposed in each representation we support.""" type CanonicalName { """ @@ -390,16 +395,14 @@ input DomainPermissionsWhereInput { user: DomainPermissionsUserFilter } -""" -PREVIEW: An interpreted ENS profile for a name. Types are defined for query ergonomics; resolution is not yet wired. -""" +"""An interpreted ENS profile for a name.""" type DomainProfile { addresses: ProfileAddresses avatar: ProfileAvatar - banner: ProfileBanner """The profile description, or null when unset.""" description: String + header: ProfileHeader socials: ProfileSocials website: ProfileWebsite } @@ -935,6 +938,11 @@ type ForwardResolve { """ acceleration: AccelerationStatus! + """ + An interpreted ENS profile for this Domain. Returns null when the name is not resolvable (non-canonical, unnormalized, or no profile field was selected). + """ + profile: DomainProfile + """ Resolved ENS records via the ENS protocol. Null when the name is not resolvable (non-canonical, unnormalized, or no records field was selected). """ @@ -1214,65 +1222,57 @@ input PrimaryNamesWhereInput @oneOf { coinTypes: [CoinType!] } -""" -PREVIEW: Interpreted address records on a Domain profile. Not yet resolved. -""" +"""Interpreted address records on a Domain profile.""" type ProfileAddresses { """The interpreted Base address, or null when unset.""" base: Address """The interpreted Bitcoin address, or null when unset.""" - bitcoin: String + bitcoin: BitcoinAddress """The interpreted Ethereum address, or null when unset.""" ethereum: Address """The interpreted Solana address, or null when unset.""" - solana: String + solana: SolanaAddress } -""" -PREVIEW: Interpreted avatar metadata on a Domain profile. Not yet resolved. -""" +"""Interpreted avatar metadata on a Domain profile.""" type ProfileAvatar { - """The resolved avatar URL, or null when unset.""" - url: String + """ + Provides a HTTP-compatible URL for fetching the avatar image that can be safely referenced as an image in web browsers. This is an abstraction over the "raw" avatar record, which may reference non-HTTP compatible URLs or encodings including IPFS urls, CAIP-22 / CAIP-29 NFT References, and more edge cases that cannot be trivially referenced as an image in most web browsers. Additional details here: https://docs.ens.domains/ensip/12 + """ + httpUrl: String } -""" -PREVIEW: Interpreted banner metadata on a Domain profile. Not yet resolved. -""" -type ProfileBanner { - """The resolved banner URL, or null when unset.""" - url: String +"""Interpreted header metadata on a Domain profile.""" +type ProfileHeader { + """ + Provides a HTTP-compatible URL for fetching the header image that can be safely referenced as an image in web browsers. This is an abstraction over the "raw" header record, which may reference non-HTTP compatible URLs or encodings including IPFS urls, CAIP-22 / CAIP-29 NFT References, and more edge cases that cannot be trivially referenced as an image in most web browsers. Additional details here: https://docs.ens.domains/ensip/12 + """ + httpUrl: String } -""" -PREVIEW: An interpreted social account on a Domain profile. Not yet resolved. -""" +"""An interpreted social account on a Domain profile.""" type ProfileSocialAccount { - """The social handle, or null when unset.""" - handle: String + """The social handle.""" + handle: String! - """The social profile URL, or null when unset.""" - url: String + """The HTTP-compatible social profile URL.""" + httpUrl: String! } -""" -PREVIEW: Interpreted social accounts on a Domain profile. Not yet resolved. -""" +"""Interpreted social accounts on a Domain profile.""" type ProfileSocials { github: ProfileSocialAccount telegram: ProfileSocialAccount twitter: ProfileSocialAccount } -""" -PREVIEW: Interpreted website metadata on a Domain profile. Not yet resolved. -""" +"""Interpreted website metadata on a Domain profile.""" type ProfileWebsite { - """The resolved website URL, or null when unset.""" - url: String + """The HTTP-compatible website URL, or null when unset.""" + httpUrl: String } type Query { @@ -1695,6 +1695,11 @@ type ReverseResolve { trace: JSON! } +""" +SolanaAddress represents a Base58-encoded Solana address (coin type 501). +""" +scalar SolanaAddress + """Filter for Domain.subdomains query.""" input SubdomainsWhereInput { """If set, filters the set of subdomains by name.""" diff --git a/packages/enssdk/src/omnigraph/graphql.ts b/packages/enssdk/src/omnigraph/graphql.ts index 0788574005..a81520f9d8 100644 --- a/packages/enssdk/src/omnigraph/graphql.ts +++ b/packages/enssdk/src/omnigraph/graphql.ts @@ -3,6 +3,7 @@ import { initGraphQLTada } from "gql.tada"; import type { BeautifiedLabel, BeautifiedName, + BitcoinAddress, ChainId, CoinType, DomainId, @@ -21,6 +22,7 @@ import type { RenewalId, ResolverId, ResolverRecordsId, + SolanaAddress, } from "../lib/types"; import type { introspection } from "./generated/introspection"; @@ -42,6 +44,8 @@ export type OmnigraphScalars = { BigInt: `${bigint}`; JSON: JsonValue; Address: NormalizedAddress; + BitcoinAddress: BitcoinAddress; + SolanaAddress: SolanaAddress; Hex: Hex; ChainId: ChainId; CoinType: CoinType; diff --git a/packages/namehash-ui/src/components/identity/EnsAvatar.tsx b/packages/namehash-ui/src/components/identity/EnsAvatar.tsx index 93963228b5..59a6341d3a 100644 --- a/packages/namehash-ui/src/components/identity/EnsAvatar.tsx +++ b/packages/namehash-ui/src/components/identity/EnsAvatar.tsx @@ -1,11 +1,11 @@ import BoringAvatar from "boring-avatars"; import type { Name } from "enssdk"; +import { getEnsMetadataServiceAvatarUrl } from "enssdk"; import * as React from "react"; import type { ENSNamespaceId } from "@ensnode/datasources"; import { cn } from "../../utils/cn"; -import { getEnsMetadataServiceAvatarUrl } from "../../utils/ensMetadata"; import { Avatar, AvatarImage } from "../ui/avatar"; interface EnsAvatarProps { diff --git a/packages/namehash-ui/src/index.ts b/packages/namehash-ui/src/index.ts index 8abc4ad926..c72f4e3df7 100644 --- a/packages/namehash-ui/src/index.ts +++ b/packages/namehash-ui/src/index.ts @@ -1,5 +1,7 @@ import "./styles.css"; +export { getEnsMetadataServiceAvatarUrl } from "enssdk"; + export * from "./components/chains/ChainIcon"; export * from "./components/chains/ChainName"; export * from "./components/datetime/AbsoluteTime"; @@ -29,4 +31,3 @@ export { useNow } from "./hooks/useNow"; export * from "./utils/blockExplorers"; export * from "./utils/chains"; export * from "./utils/ensManager"; -export * from "./utils/ensMetadata"; diff --git a/packages/namehash-ui/src/utils/ensMetadata.ts b/packages/namehash-ui/src/utils/ensMetadata.ts deleted file mode 100644 index 4f81011798..0000000000 --- a/packages/namehash-ui/src/utils/ensMetadata.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { Name } from "enssdk"; - -import type { ENSNamespaceId } from "@ensnode/datasources"; -import { ENSNamespaceIds } from "@ensnode/ensnode-sdk"; - -/** - * Build the avatar image URL for a name on the given ENS Namespace that (once fetched) would - * load the avatar image for the given name from the ENS Metadata Service - * (https://metadata.ens.domains/docs). - * - * The returned URL is dynamically built based on the provided ENS namespace. Not all ENS - * namespaces are supported by the ENS Metadata Service. Therefore, the returned URL may - * be null. - * - * @param {Name} name - ENS name to build the avatar image URL for - * @param {ENSNamespaceId} namespaceId - ENS Namespace identifier - * @returns avatar image URL for the name on the given ENS Namespace, or null if the given - * ENS namespace is not supported by the ENS Metadata Service - */ -export function getEnsMetadataServiceAvatarUrl( - name: Name, - namespaceId: ENSNamespaceId, -): URL | null { - switch (namespaceId) { - case ENSNamespaceIds.Mainnet: - return new URL(name, `https://metadata.ens.domains/mainnet/avatar/`); - case ENSNamespaceIds.Sepolia: - return new URL(name, `https://metadata.ens.domains/sepolia/avatar/`); - case ENSNamespaceIds.SepoliaV2: - // sepolia-v2 is an ephemeral test deployment of ensv2 to sepolia and doesn't have a metadata service - return null; - case ENSNamespaceIds.EnsTestEnv: - // ens-test-env runs on a local chain and is not supported by metadata.ens.domains - // TODO: Above comment is not true. Details at https://github.com/namehash/ensnode/issues/1078 - return null; - } -} From d25b8ce35bf8d0259139d2ce81b4e57ff9652095 Mon Sep 17 00:00:00 2001 From: sevenzing Date: Mon, 1 Jun 2026 12:53:56 +0300 Subject: [PATCH 02/19] feat(ensapi): add email, linkedin, keybase, vnd fallbacks, and ENSIP-9 coin address parsers to Domain.profile --- apps/ensapi/src/omnigraph-api/builder.ts | 14 ++ .../lib/resolution/profile/README.md | 18 +- .../profile/build-profile-selection.test.ts | 6 +- .../profile/build-profile-selection.ts | 4 + .../profile/parsers/addresses.test.ts | 43 +++++ .../resolution/profile/parsers/addresses.ts | 64 ++++++- .../lib/resolution/profile/parsers/index.ts | 11 +- .../resolution/profile/parsers/social.test.ts | 165 +++++++++++++++++- .../lib/resolution/profile/parsers/social.ts | 46 ++++- .../resolution/profile/parsers/texts.test.ts | 22 ++- .../lib/resolution/profile/parsers/texts.ts | 1 + .../src/omnigraph-api/schema/profile.ts | 58 ++++++ .../src/omnigraph-api/schema/scalars.ts | 54 ++++++ packages/enssdk/src/lib/types/addresses.ts | 35 ++++ .../src/omnigraph/generated/schema.graphql | 61 +++++++ packages/enssdk/src/omnigraph/graphql.ts | 14 ++ 16 files changed, 587 insertions(+), 29 deletions(-) diff --git a/apps/ensapi/src/omnigraph-api/builder.ts b/apps/ensapi/src/omnigraph-api/builder.ts index 6af1452606..0b064624f1 100644 --- a/apps/ensapi/src/omnigraph-api/builder.ts +++ b/apps/ensapi/src/omnigraph-api/builder.ts @@ -8,15 +8,20 @@ import { AttributeNames, createOpenTelemetryWrapper } from "@pothos/tracing-open import type { BeautifiedLabel, BeautifiedName, + BinanceAddress, BitcoinAddress, + BitcoinCashAddress, ChainId, CoinType, + DogecoinAddress, DomainId, Hex, InterfaceId, InterpretedLabel, InterpretedName, JsonValue, + LitecoinAddress, + MonacoinAddress, Node, NormalizedAddress, PermissionsId, @@ -27,6 +32,8 @@ import type { RenewalId, ResolverId, ResolverRecordsId, + RippleAddress, + RootstockAddress, SolanaAddress, } from "enssdk"; import { getNamedType } from "graphql"; @@ -68,6 +75,13 @@ export type BuilderScalars = { JSON: { Input: JsonValue; Output: JsonValue }; Address: { Input: NormalizedAddress; Output: NormalizedAddress }; BitcoinAddress: { Input: BitcoinAddress; Output: BitcoinAddress }; + LitecoinAddress: { Input: LitecoinAddress; Output: LitecoinAddress }; + DogecoinAddress: { Input: DogecoinAddress; Output: DogecoinAddress }; + MonacoinAddress: { Input: MonacoinAddress; Output: MonacoinAddress }; + RootstockAddress: { Input: RootstockAddress; Output: RootstockAddress }; + RippleAddress: { Input: RippleAddress; Output: RippleAddress }; + BitcoinCashAddress: { Input: BitcoinCashAddress; Output: BitcoinCashAddress }; + BinanceAddress: { Input: BinanceAddress; Output: BinanceAddress }; SolanaAddress: { Input: SolanaAddress; Output: SolanaAddress }; Hex: { Input: Hex; Output: Hex }; ChainId: { Input: ChainId; Output: ChainId }; diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/README.md b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/README.md index 6852c69cf0..87bbebdd21 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/README.md +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/README.md @@ -43,7 +43,7 @@ Record names use a `texts.` prefix for ENS text records and `addresses.` prefix for ENS text records and `addresses.][]` | 📋 | [25](https://docs.ens.domains/ensip/25) | Non-empty attestation linking an ENS name to an on-chain AI agent registry entry. | @@ -67,7 +67,13 @@ Record names use a `texts.` prefix for ENS text records and `addresses. { [ "socials.github", "profile { socials { github { handle httpUrl } } }", - { texts: ["com.github"] }, + { texts: ["com.github", "vnd.github"] }, ], [ "socials.twitter", "profile { socials { twitter { handle httpUrl } } }", - { texts: ["com.twitter"] }, + { texts: ["com.twitter", "vnd.twitter"] }, ], [ "socials.telegram", @@ -89,7 +89,7 @@ describe("buildProfileSelectionFromResolveContainerInfo", () => { } `, { - texts: ["description", "avatar", "com.github", "com.twitter"], + texts: ["description", "avatar", "com.github", "vnd.github", "com.twitter", "vnd.twitter"], addresses: [60, 0], }, ], diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/build-profile-selection.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/build-profile-selection.ts index b231917e90..25f63f7197 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/build-profile-selection.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/build-profile-selection.ts @@ -11,6 +11,7 @@ import { ADDRESS_PARSERS, ProfileAvatarParser, ProfileDescriptionParser, + ProfileEmailParser, ProfileHeaderParser, ProfileWebsiteParser, SOCIAL_PARSERS, @@ -92,6 +93,9 @@ export function buildProfileSelectionFromResolveContainerInfo( if (topLevelFields.has("website")) { merged = mergeRecordsSelections(merged, ProfileWebsiteParser.selection); } + if (topLevelFields.has("email")) { + merged = mergeRecordsSelections(merged, ProfileEmailParser.selection); + } // 3. Walk socials sub-fields const socialsNodes = collectSubFieldNodes(profileNodes, "socials", info); diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/addresses.test.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/addresses.test.ts index 663f3a57fa..966d77ee9e 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/addresses.test.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/addresses.test.ts @@ -29,6 +29,49 @@ describe("ADDRESS_PARSERS", () => { "0x8a11e71b96cabbe3216e3153b09694f39fc85022cbc076f79846a3ab4d8c1991", "AHy6YZA8BsHgQfVkk7MbwpAN94iyN7Nf1zN4nPqUN32Q", ], + // ENSIP-9 test vectors + [ + "litecoin", + 2, + "0x76a914a5f4d12ce3685781b227c1f39548ddef429e978388ac", + "LaMT348PWRnrqeeWArpwQPbuanpXDZGEUz", + ], + [ + "dogecoin", + 3, + "0x76a9144620b70031f0e9437e374a2100934fba4911046088ac", + "DBXu2kgc3xtvCUWFcxFE3r9hEYgmuaaCyD", + ], + [ + "monacoin", + 22, + "0x76a9146e5bb7226a337fe8307b4192ae5c3fab9fa9edf588ac", + "MHxgS2XMXjeJ4if2PRRbWYcdwZPWfdwaDT", + ], + [ + "rootstock", + 137, + "0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaed", + "0x5aaEB6053f3e94c9b9a09f33669435E7ef1bEAeD", + ], + [ + "ripple", + 144, + "0x004b4e9c06f24296074f7bc48f92a97916c6dc5ea9", + "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn", + ], + [ + "bitcoincash", + 145, + "0x76a91476a04053bda0a88bda5177b86a15c3b29f55987388ac", + "bitcoincash:qpm2qsznhks23z7629mms6s4cwef74vcwvy22gdx6a", + ], + [ + "binance", + 714, + "0x40c2979694bbc961023d1d27be6fc4d21a9febe6", + "bnb1grpf0955h0ykzq3ar5nmum7y6gdfl6lxfn46h2", + ], ] as const)("parses %s address", (field, coinType, raw, expected) => { expect(ADDRESS_PARSERS[field].selection).toEqual({ addresses: [coinType] }); expect(ADDRESS_PARSERS[field].parse(profileRecordsModel({}, { [coinType]: raw }))).toBe( diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/addresses.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/addresses.ts index 6086491303..f7d8d7a005 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/addresses.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/addresses.ts @@ -1,20 +1,36 @@ -import { type CoinName, getCoderByCoinName } from "@ensdomains/address-encoder"; +import { type CoinName, getCoderByCoinName, getCoderByCoinType } from "@ensdomains/address-encoder"; import { hexToBytes } from "@ensdomains/address-encoder/utils"; -import { type BitcoinAddress, type SolanaAddress, toNormalizedAddress } from "enssdk"; +import { + type BinanceAddress, + type BitcoinAddress, + type BitcoinCashAddress, + type CoinType, + type DogecoinAddress, + type LitecoinAddress, + type MonacoinAddress, + type RippleAddress, + type RootstockAddress, + type SolanaAddress, + toNormalizedAddress, +} from "enssdk"; import { isHex } from "viem"; import type { ProfileFieldParser } from "./types"; const buildAddressParser = ( - coinName: CoinName, + coinNameOrType: CoinName | number, format: (encoded: string) => T, ): ProfileFieldParser => { - const coder = getCoderByCoinName(coinName); + const coder = + typeof coinNameOrType === "number" + ? getCoderByCoinType(coinNameOrType) + : getCoderByCoinName(coinNameOrType); + const coinType = coder.coinType as CoinType; return { - selection: { addresses: [coder.coinType] }, + selection: { addresses: [coinType] }, parse: (records) => { - const raw = records.addresses?.[coder.coinType]; + const raw = records.addresses?.[coinType]; if (raw == null || raw === "" || raw === "0x") return null; if (!isHex(raw)) return null; @@ -40,6 +56,35 @@ export const ProfileAddressSolanaParser = buildAddressParser( "sol", (address) => address as SolanaAddress, ); +export const ProfileAddressLitecoinParser = buildAddressParser( + "ltc", + (address) => address as LitecoinAddress, +); +export const ProfileAddressDogecoinParser = buildAddressParser( + "doge", + (address) => address as DogecoinAddress, +); +export const ProfileAddressMonacoinParser = buildAddressParser( + "mona", + (address) => address as MonacoinAddress, +); +/** Rootstock (RBTC) — coinType 137, EIP-55 checksummed EVM address. */ +export const ProfileAddressRootstockParser = buildAddressParser( + 137, + (address) => address as RootstockAddress, +); +export const ProfileAddressRippleParser = buildAddressParser( + "xrp", + (address) => address as RippleAddress, +); +export const ProfileAddressBitcoinCashParser = buildAddressParser( + "bch", + (address) => address as BitcoinCashAddress, +); +export const ProfileAddressBinanceParser = buildAddressParser( + "bnb", + (address) => address as BinanceAddress, +); /** All address parsers keyed by their GraphQL field name. */ export const ADDRESS_PARSERS = { @@ -47,4 +92,11 @@ export const ADDRESS_PARSERS = { base: ProfileAddressBaseParser, bitcoin: ProfileAddressBitcoinParser, solana: ProfileAddressSolanaParser, + litecoin: ProfileAddressLitecoinParser, + dogecoin: ProfileAddressDogecoinParser, + monacoin: ProfileAddressMonacoinParser, + rootstock: ProfileAddressRootstockParser, + ripple: ProfileAddressRippleParser, + bitcoincash: ProfileAddressBitcoinCashParser, + binance: ProfileAddressBinanceParser, } as const satisfies Record>; diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/index.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/index.ts index 75098d689b..49c8376cb9 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/index.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/index.ts @@ -1,8 +1,15 @@ export { ADDRESS_PARSERS, ProfileAddressBaseParser, + ProfileAddressBinanceParser, + ProfileAddressBitcoinCashParser, ProfileAddressBitcoinParser, + ProfileAddressDogecoinParser, ProfileAddressEthereumParser, + ProfileAddressLitecoinParser, + ProfileAddressMonacoinParser, + ProfileAddressRippleParser, + ProfileAddressRootstockParser, ProfileAddressSolanaParser, } from "./addresses"; export type { ProfileImageResult } from "./images"; @@ -15,8 +22,10 @@ export { export { SOCIAL_PARSERS, SocialGithubParser, + SocialKeybaseParser, + SocialLinkedInParser, SocialTelegramParser, SocialTwitterParser, } from "./social"; -export { ProfileDescriptionParser, ProfileWebsiteParser } from "./texts"; +export { ProfileDescriptionParser, ProfileEmailParser, ProfileWebsiteParser } from "./texts"; export type { ProfileFieldParser } from "./types"; diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/social.test.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/social.test.ts index 299a0956aa..e7ab70f072 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/social.test.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/social.test.ts @@ -1,11 +1,17 @@ import { describe, expect, it } from "vitest"; -import { SocialGithubParser, SocialTelegramParser, SocialTwitterParser } from "./social"; +import { + SocialGithubParser, + SocialKeybaseParser, + SocialLinkedInParser, + SocialTelegramParser, + SocialTwitterParser, +} from "./social"; import { profileRecordsModel } from "./test-helpers"; describe("SocialGithubParser", () => { it("has correct selection", () => { - expect(SocialGithubParser.selection).toEqual({ texts: ["com.github"] }); + expect(SocialGithubParser.selection).toEqual({ texts: ["com.github", "vnd.github"] }); }); it.each([ @@ -101,7 +107,7 @@ describe("SocialGithubParser", () => { describe("SocialTwitterParser", () => { it("has correct selection", () => { - expect(SocialTwitterParser.selection).toEqual({ texts: ["com.twitter"] }); + expect(SocialTwitterParser.selection).toEqual({ texts: ["com.twitter", "vnd.twitter"] }); }); it.each([ @@ -201,3 +207,156 @@ describe("SocialTelegramParser", () => { expect(SocialTelegramParser.parse(profileRecordsModel(texts))).toBeNull(); }); }); + +describe("SocialGithubParser (vnd.github fallback)", () => { + it("has correct selection (includes vnd.github)", () => { + expect(SocialGithubParser.selection).toEqual({ texts: ["com.github", "vnd.github"] }); + }); + + it("falls back to vnd.github when com.github is unset", () => { + expect(SocialGithubParser.parse(profileRecordsModel({ "vnd.github": "itslevchiks" }))).toEqual({ + handle: "itslevchiks", + httpUrl: "https://github.com/itslevchiks", + }); + }); + + it("prefers com.github over vnd.github", () => { + expect( + SocialGithubParser.parse( + profileRecordsModel({ "com.github": "primary-user", "vnd.github": "legacy-user" }), + ), + ).toEqual({ handle: "primary-user", httpUrl: "https://github.com/primary-user" }); + }); + + it("falls back to vnd.github when com.github is empty", () => { + expect( + SocialGithubParser.parse( + profileRecordsModel({ "com.github": "", "vnd.github": "legacy-user" }), + ), + ).toEqual({ handle: "legacy-user", httpUrl: "https://github.com/legacy-user" }); + }); +}); + +describe("SocialTwitterParser (vnd.twitter fallback)", () => { + it("has correct selection (includes vnd.twitter)", () => { + expect(SocialTwitterParser.selection).toEqual({ texts: ["com.twitter", "vnd.twitter"] }); + }); + + it("falls back to vnd.twitter when com.twitter is unset", () => { + expect( + SocialTwitterParser.parse(profileRecordsModel({ "vnd.twitter": "itslevchiks" })), + ).toEqual({ handle: "itslevchiks", httpUrl: "https://x.com/itslevchiks" }); + }); + + it("prefers com.twitter over vnd.twitter", () => { + expect( + SocialTwitterParser.parse( + profileRecordsModel({ "com.twitter": "primaryuser", "vnd.twitter": "legacyuser" }), + ), + ).toEqual({ handle: "primaryuser", httpUrl: "https://x.com/primaryuser" }); + }); +}); + +describe("SocialLinkedInParser", () => { + it("has correct selection", () => { + expect(SocialLinkedInParser.selection).toEqual({ texts: ["com.linkedin"] }); + }); + + it.each([ + [ + "bare handle", + "itslevchiks", + { handle: "itslevchiks", httpUrl: "https://www.linkedin.com/in/itslevchiks" }, + ], + [ + "@ prefix", + "@itslevchiks", + { handle: "itslevchiks", httpUrl: "https://www.linkedin.com/in/itslevchiks" }, + ], + [ + "https URL", + "https://linkedin.com/in/itslevchiks", + { handle: "itslevchiks", httpUrl: "https://www.linkedin.com/in/itslevchiks" }, + ], + [ + "www hostname", + "https://www.linkedin.com/in/itslevchiks", + { handle: "itslevchiks", httpUrl: "https://www.linkedin.com/in/itslevchiks" }, + ], + [ + "handle with hyphen", + "my-handle", + { handle: "my-handle", httpUrl: "https://www.linkedin.com/in/my-handle" }, + ], + [ + "trailing slash", + "https://linkedin.com/in/itslevchiks/", + { handle: "itslevchiks", httpUrl: "https://www.linkedin.com/in/itslevchiks" }, + ], + ])("parses %s", (_message, input, expected) => { + expect(SocialLinkedInParser.parse(profileRecordsModel({ "com.linkedin": input }))).toEqual( + expected, + ); + }); + + it.each([ + ["record unset", {}], + ["empty string", { "com.linkedin": "" }], + ["invalid handle characters", { "com.linkedin": "bad handle!" }], + ["foreign social URL", { "com.linkedin": "https://twitter.com/itslevchiks" }], + ])("returns null: %s", (_message, texts) => { + expect(SocialLinkedInParser.parse(profileRecordsModel(texts))).toBeNull(); + }); +}); + +describe("SocialKeybaseParser", () => { + it("has correct selection", () => { + expect(SocialKeybaseParser.selection).toEqual({ texts: ["io.keybase"] }); + }); + + it.each([ + [ + "bare handle", + "itslevchiks", + { handle: "itslevchiks", httpUrl: "https://keybase.io/itslevchiks" }, + ], + [ + "@ prefix", + "@itslevchiks", + { handle: "itslevchiks", httpUrl: "https://keybase.io/itslevchiks" }, + ], + [ + "https URL", + "https://keybase.io/itslevchiks", + { handle: "itslevchiks", httpUrl: "https://keybase.io/itslevchiks" }, + ], + [ + "http URL", + "http://keybase.io/itslevchiks", + { handle: "itslevchiks", httpUrl: "https://keybase.io/itslevchiks" }, + ], + [ + "without scheme", + "keybase.io/itslevchiks", + { handle: "itslevchiks", httpUrl: "https://keybase.io/itslevchiks" }, + ], + [ + "trailing slash", + "keybase.io/itslevchiks/", + { handle: "itslevchiks", httpUrl: "https://keybase.io/itslevchiks" }, + ], + ])("parses %s", (_message, input, expected) => { + expect(SocialKeybaseParser.parse(profileRecordsModel({ "io.keybase": input }))).toEqual( + expected, + ); + }); + + it.each([ + ["record unset", {}], + ["empty string", { "io.keybase": "" }], + ["invalid handle characters", { "io.keybase": "bad handle!" }], + ["foreign social URL", { "io.keybase": "https://github.com/itslevchiks" }], + ])("returns null: %s", (_message, texts) => { + expect(SocialKeybaseParser.parse(profileRecordsModel(texts))).toBeNull(); + }); +}); diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/social.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/social.ts index cd5c7baec9..72396d1caf 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/social.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/social.ts @@ -14,6 +14,12 @@ export type ParseSocialHandleOptions = { baseUrl: string; /** Regex pattern the extracted handle must match. */ handlePattern: RegExp; + /** + * Number of leading path segments to skip when extracting the handle from a URL. + * Use when the profile handle lives at a sub-path (e.g. `/in/` on LinkedIn → 1). + * Defaults to 0. + */ + pathOffset?: number; }; /** @@ -35,6 +41,7 @@ export function parseSocialHandle({ hostnames, baseUrl, handlePattern, + pathOffset = 0, }: ParseSocialHandleOptions): SocialHandleResult | null { const raw = value?.trim(); if (!raw) return null; @@ -48,7 +55,7 @@ export function parseSocialHandle({ const url = new URL(toParse); if (hostnames.includes(url.hostname)) { const segments = url.pathname.split("/").filter((s) => s.length > 0); - handle = segments.join("/") ?? null; + handle = segments.slice(pathOffset).join("/") ?? null; if (handle) { const baseUrlParsed = new URL(baseUrl); @@ -77,15 +84,17 @@ const socialParser = ( hostnames: readonly string[], baseUrl: string, handlePattern: RegExp, + options?: { fallbackTextKey?: string; pathOffset?: number }, ): ProfileFieldParser => ({ - selection: { texts: [textKey] }, - parse: (records) => - parseSocialHandle({ - value: records.texts?.[textKey], - hostnames, - baseUrl, - handlePattern, - }), + selection: { + texts: options?.fallbackTextKey ? [textKey, options.fallbackTextKey] : [textKey], + }, + parse: (records) => { + const opts = { hostnames, baseUrl, handlePattern, pathOffset: options?.pathOffset }; + const primary = parseSocialHandle({ value: records.texts?.[textKey], ...opts }); + if (primary !== null || options?.fallbackTextKey === undefined) return primary; + return parseSocialHandle({ value: records.texts?.[options.fallbackTextKey], ...opts }); + }, }); export const SocialGithubParser: ProfileFieldParser = socialParser( @@ -93,6 +102,7 @@ export const SocialGithubParser: ProfileFieldParser = social ["github.com", "www.github.com"], "https://github.com", /^[A-Za-z0-9_./-]+$/, + { fallbackTextKey: "vnd.github" }, ); export const SocialTwitterParser: ProfileFieldParser = socialParser( @@ -100,6 +110,7 @@ export const SocialTwitterParser: ProfileFieldParser = socia ["twitter.com", "www.twitter.com", "x.com", "www.x.com"], "https://x.com", /^[A-Za-z0-9_]+$/, + { fallbackTextKey: "vnd.twitter" }, ); export const SocialTelegramParser: ProfileFieldParser = socialParser( @@ -109,9 +120,26 @@ export const SocialTelegramParser: ProfileFieldParser = soci /^[A-Za-z0-9_]+$/, ); +export const SocialLinkedInParser: ProfileFieldParser = socialParser( + "com.linkedin", + ["linkedin.com", "www.linkedin.com"], + "https://www.linkedin.com/in", + /^[A-Za-z0-9_-]+$/, + { pathOffset: 1 }, +); + +export const SocialKeybaseParser: ProfileFieldParser = socialParser( + "io.keybase", + ["keybase.io", "www.keybase.io"], + "https://keybase.io", + /^[A-Za-z0-9_]+$/, +); + /** All social parsers keyed by their GraphQL field name. */ export const SOCIAL_PARSERS = { github: SocialGithubParser, twitter: SocialTwitterParser, telegram: SocialTelegramParser, + linkedin: SocialLinkedInParser, + keybase: SocialKeybaseParser, } as const satisfies Record>; diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/texts.test.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/texts.test.ts index 0f5d63887e..27c0a32630 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/texts.test.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/texts.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { profileRecordsModel } from "./test-helpers"; -import { ProfileDescriptionParser, ProfileWebsiteParser } from "./texts"; +import { ProfileDescriptionParser, ProfileEmailParser, ProfileWebsiteParser } from "./texts"; describe("ProfileDescriptionParser", () => { it("has correct selection", () => { @@ -42,3 +42,23 @@ describe("ProfileWebsiteParser", () => { expect(ProfileWebsiteParser.parse(profileRecordsModel(texts))).toBeNull(); }); }); + +describe("ProfileEmailParser", () => { + it("has correct selection", () => { + expect(ProfileEmailParser.selection).toEqual({ texts: ["email"] }); + }); + + it.each([ + ["plain email", { email: "user@example.com" }, "user@example.com"], + ["email with dots", { email: "first.last@example.org" }, "first.last@example.org"], + ])("parses %s", (_message, texts, expected) => { + expect(ProfileEmailParser.parse(profileRecordsModel(texts))).toBe(expected); + }); + + it.each([ + ["record unset", {}], + ["empty string", { email: "" }], + ])("returns null: %s", (_message, texts) => { + expect(ProfileEmailParser.parse(profileRecordsModel(texts))).toBeNull(); + }); +}); diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/texts.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/texts.ts index 284aa38611..13556d192b 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/texts.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/texts.ts @@ -10,4 +10,5 @@ const textParser = (key: string): ProfileFieldParser => ({ }); export const ProfileDescriptionParser: ProfileFieldParser = textParser("description"); +export const ProfileEmailParser: ProfileFieldParser = textParser("email"); export const ProfileWebsiteParser: ProfileFieldParser = textParser("url"); diff --git a/apps/ensapi/src/omnigraph-api/schema/profile.ts b/apps/ensapi/src/omnigraph-api/schema/profile.ts index fad8609fd3..5f231faf85 100644 --- a/apps/ensapi/src/omnigraph-api/schema/profile.ts +++ b/apps/ensapi/src/omnigraph-api/schema/profile.ts @@ -4,6 +4,7 @@ import { interpretProfileWebsiteHttpUrl, ProfileAvatarParser, ProfileDescriptionParser, + ProfileEmailParser, ProfileHeaderParser, ProfileWebsiteParser, profileImageHttpUrlDescription, @@ -51,6 +52,16 @@ ProfileSocialsRef.implement({ nullable: true, resolve: (model) => SOCIAL_PARSERS.twitter.parse(model), }), + linkedin: t.field({ + type: ProfileSocialAccountRef, + nullable: true, + resolve: (model) => SOCIAL_PARSERS.linkedin.parse(model), + }), + keybase: t.field({ + type: ProfileSocialAccountRef, + nullable: true, + resolve: (model) => SOCIAL_PARSERS.keybase.parse(model), + }), }), }); @@ -83,6 +94,48 @@ ProfileAddressesRef.implement({ nullable: true, resolve: (model) => ADDRESS_PARSERS.solana.parse(model), }), + litecoin: t.field({ + description: "The interpreted Litecoin address, or null when unset.", + type: "LitecoinAddress", + nullable: true, + resolve: (model) => ADDRESS_PARSERS.litecoin.parse(model), + }), + dogecoin: t.field({ + description: "The interpreted Dogecoin address, or null when unset.", + type: "DogecoinAddress", + nullable: true, + resolve: (model) => ADDRESS_PARSERS.dogecoin.parse(model), + }), + monacoin: t.field({ + description: "The interpreted Monacoin address, or null when unset.", + type: "MonacoinAddress", + nullable: true, + resolve: (model) => ADDRESS_PARSERS.monacoin.parse(model), + }), + rootstock: t.field({ + description: "The interpreted Rootstock (RBTC) address, or null when unset.", + type: "RootstockAddress", + nullable: true, + resolve: (model) => ADDRESS_PARSERS.rootstock.parse(model), + }), + ripple: t.field({ + description: "The interpreted Ripple (XRP) address, or null when unset.", + type: "RippleAddress", + nullable: true, + resolve: (model) => ADDRESS_PARSERS.ripple.parse(model), + }), + bitcoincash: t.field({ + description: "The interpreted Bitcoin Cash address, or null when unset.", + type: "BitcoinCashAddress", + nullable: true, + resolve: (model) => ADDRESS_PARSERS.bitcoincash.parse(model), + }), + binance: t.field({ + description: "The interpreted Binance Chain (BNB) address, or null when unset.", + type: "BinanceAddress", + nullable: true, + resolve: (model) => ADDRESS_PARSERS.binance.parse(model), + }), }), }); @@ -148,6 +201,11 @@ DomainProfileRef.implement({ nullable: true, resolve: (model) => ProfileDescriptionParser.parse(model), }), + email: t.string({ + description: "The contact email address, or null when unset.", + nullable: true, + resolve: (model) => ProfileEmailParser.parse(model), + }), addresses: t.field({ type: ProfileAddressesRef, nullable: true, diff --git a/apps/ensapi/src/omnigraph-api/schema/scalars.ts b/apps/ensapi/src/omnigraph-api/schema/scalars.ts index 90825b519c..1e1271e329 100644 --- a/apps/ensapi/src/omnigraph-api/schema/scalars.ts +++ b/apps/ensapi/src/omnigraph-api/schema/scalars.ts @@ -2,9 +2,12 @@ import { getCoderByCoinType } from "@ensdomains/address-encoder"; import { type BeautifiedLabel, type BeautifiedName, + type BinanceAddress, type BitcoinAddress, + type BitcoinCashAddress, type ChainId, type CoinType, + type DogecoinAddress, type DomainId, type Hex, type InterfaceId, @@ -14,6 +17,8 @@ import { isInterpretedLabel, isInterpretedName, type JsonValue, + type LitecoinAddress, + type MonacoinAddress, type Name, type Node, type NormalizedAddress, @@ -25,6 +30,8 @@ import { type RenewalId, type ResolverId, type ResolverRecordsId, + type RippleAddress, + type RootstockAddress, type SolanaAddress, } from "enssdk"; import { isHex, size } from "viem"; @@ -81,6 +88,53 @@ builder.scalarType("SolanaAddress", { parseValue: (value) => makeCoinAddressSchema("Solana", 501).parse(value) as SolanaAddress, }); +builder.scalarType("LitecoinAddress", { + description: "LitecoinAddress represents a Base58Check-encoded Litecoin address (coin type 2).", + serialize: (value: LitecoinAddress) => value, + parseValue: (value) => makeCoinAddressSchema("Litecoin", 2).parse(value) as LitecoinAddress, +}); + +builder.scalarType("DogecoinAddress", { + description: "DogecoinAddress represents a Base58Check-encoded Dogecoin address (coin type 3).", + serialize: (value: DogecoinAddress) => value, + parseValue: (value) => makeCoinAddressSchema("Dogecoin", 3).parse(value) as DogecoinAddress, +}); + +builder.scalarType("MonacoinAddress", { + description: "MonacoinAddress represents a Base58Check-encoded Monacoin address (coin type 22).", + serialize: (value: MonacoinAddress) => value, + parseValue: (value) => makeCoinAddressSchema("Monacoin", 22).parse(value) as MonacoinAddress, +}); + +builder.scalarType("RootstockAddress", { + description: + "RootstockAddress represents an EIP-55 checksummed Rootstock (RBTC) address (coin type 137).", + serialize: (value: RootstockAddress) => value, + parseValue: (value) => makeCoinAddressSchema("Rootstock", 137).parse(value) as RootstockAddress, +}); + +builder.scalarType("RippleAddress", { + description: + "RippleAddress represents a Base58Check-encoded Ripple (XRP) address (coin type 144).", + serialize: (value: RippleAddress) => value, + parseValue: (value) => makeCoinAddressSchema("Ripple", 144).parse(value) as RippleAddress, +}); + +builder.scalarType("BitcoinCashAddress", { + description: + "BitcoinCashAddress represents a CashAddr-encoded Bitcoin Cash address (coin type 145).", + serialize: (value: BitcoinCashAddress) => value, + parseValue: (value) => + makeCoinAddressSchema("Bitcoin Cash", 145).parse(value) as BitcoinCashAddress, +}); + +builder.scalarType("BinanceAddress", { + description: + "BinanceAddress represents a Bech32-encoded Binance Chain (BNB) address (coin type 714).", + serialize: (value: BinanceAddress) => value, + parseValue: (value) => makeCoinAddressSchema("Binance", 714).parse(value) as BinanceAddress, +}); + builder.scalarType("Hex", { description: "Hex represents viem#Hex.", serialize: (value: Hex) => value, diff --git a/packages/enssdk/src/lib/types/addresses.ts b/packages/enssdk/src/lib/types/addresses.ts index 2cb3e1a086..d1738481e3 100644 --- a/packages/enssdk/src/lib/types/addresses.ts +++ b/packages/enssdk/src/lib/types/addresses.ts @@ -3,6 +3,41 @@ */ export type BitcoinAddress = string; +/** + * Base58Check-encoded Litecoin address (SLIP-44 coin type 2). + */ +export type LitecoinAddress = string; + +/** + * Base58Check-encoded Dogecoin address (SLIP-44 coin type 3). + */ +export type DogecoinAddress = string; + +/** + * Base58Check-encoded Monacoin address (SLIP-44 coin type 22). + */ +export type MonacoinAddress = string; + +/** + * EIP-55 checksummed Rootstock (RBTC) address (SLIP-44 coin type 137). + */ +export type RootstockAddress = string; + +/** + * Base58Check-encoded Ripple (XRP) address (SLIP-44 coin type 144). + */ +export type RippleAddress = string; + +/** + * CashAddr-encoded Bitcoin Cash address (SLIP-44 coin type 145). + */ +export type BitcoinCashAddress = string; + +/** + * Bech32-encoded Binance Chain (BNB) address (SLIP-44 coin type 714). + */ +export type BinanceAddress = string; + /** * Base58-encoded Solana address (SLIP-44 coin type 501). */ diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index 3398130388..10d938a0bd 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -237,11 +237,21 @@ scalar BeautifiedName """BigInt represents non-fractional signed whole numeric values.""" scalar BigInt +""" +BinanceAddress represents a Bech32-encoded Binance Chain (BNB) address (coin type 714). +""" +scalar BinanceAddress + """ BitcoinAddress represents a Base58Check-encoded Bitcoin address (coin type 0). """ scalar BitcoinAddress +""" +BitcoinCashAddress represents a CashAddr-encoded Bitcoin Cash address (coin type 145). +""" +scalar BitcoinCashAddress + """A Canonical Name, exposed in each representation we support.""" type CanonicalName { """ @@ -275,6 +285,11 @@ enum ChainName { """CoinType represents an enssdk#CoinType.""" scalar CoinType +""" +DogecoinAddress represents a Base58Check-encoded Dogecoin address (coin type 3). +""" +scalar DogecoinAddress + """ Represents a Domain, i.e. an individual Label within the ENS namegraph. It may or may not be Canonical. It may be an ENSv1Domain or an ENSv2Domain. """ @@ -402,6 +417,9 @@ type DomainProfile { """The profile description, or null when unset.""" description: String + + """The contact email address, or null when unset.""" + email: String header: ProfileHeader socials: ProfileSocials website: ProfileWebsite @@ -992,6 +1010,16 @@ type Label { interpreted: InterpretedLabel! } +""" +LitecoinAddress represents a Base58Check-encoded Litecoin address (coin type 2). +""" +scalar LitecoinAddress + +""" +MonacoinAddress represents a Base58Check-encoded Monacoin address (coin type 22). +""" +scalar MonacoinAddress + """Constructs a reference to a specific Node via one of `name` or `node`.""" input NameOrNodeInput @oneOf { name: InterpretedName @@ -1227,12 +1255,33 @@ type ProfileAddresses { """The interpreted Base address, or null when unset.""" base: Address + """The interpreted Binance Chain (BNB) address, or null when unset.""" + binance: BinanceAddress + """The interpreted Bitcoin address, or null when unset.""" bitcoin: BitcoinAddress + """The interpreted Bitcoin Cash address, or null when unset.""" + bitcoincash: BitcoinCashAddress + + """The interpreted Dogecoin address, or null when unset.""" + dogecoin: DogecoinAddress + """The interpreted Ethereum address, or null when unset.""" ethereum: Address + """The interpreted Litecoin address, or null when unset.""" + litecoin: LitecoinAddress + + """The interpreted Monacoin address, or null when unset.""" + monacoin: MonacoinAddress + + """The interpreted Ripple (XRP) address, or null when unset.""" + ripple: RippleAddress + + """The interpreted Rootstock (RBTC) address, or null when unset.""" + rootstock: RootstockAddress + """The interpreted Solana address, or null when unset.""" solana: SolanaAddress } @@ -1265,6 +1314,8 @@ type ProfileSocialAccount { """Interpreted social accounts on a Domain profile.""" type ProfileSocials { github: ProfileSocialAccount + keybase: ProfileSocialAccount + linkedin: ProfileSocialAccount telegram: ProfileSocialAccount twitter: ProfileSocialAccount } @@ -1695,6 +1746,16 @@ type ReverseResolve { trace: JSON! } +""" +RippleAddress represents a Base58Check-encoded Ripple (XRP) address (coin type 144). +""" +scalar RippleAddress + +""" +RootstockAddress represents an EIP-55 checksummed Rootstock (RBTC) address (coin type 137). +""" +scalar RootstockAddress + """ SolanaAddress represents a Base58-encoded Solana address (coin type 501). """ diff --git a/packages/enssdk/src/omnigraph/graphql.ts b/packages/enssdk/src/omnigraph/graphql.ts index a81520f9d8..229be7e888 100644 --- a/packages/enssdk/src/omnigraph/graphql.ts +++ b/packages/enssdk/src/omnigraph/graphql.ts @@ -3,15 +3,20 @@ import { initGraphQLTada } from "gql.tada"; import type { BeautifiedLabel, BeautifiedName, + BinanceAddress, BitcoinAddress, + BitcoinCashAddress, ChainId, CoinType, + DogecoinAddress, DomainId, Hex, InterfaceId, InterpretedLabel, InterpretedName, JsonValue, + LitecoinAddress, + MonacoinAddress, Node, NormalizedAddress, PermissionsId, @@ -22,6 +27,8 @@ import type { RenewalId, ResolverId, ResolverRecordsId, + RippleAddress, + RootstockAddress, SolanaAddress, } from "../lib/types"; import type { introspection } from "./generated/introspection"; @@ -45,6 +52,13 @@ export type OmnigraphScalars = { JSON: JsonValue; Address: NormalizedAddress; BitcoinAddress: BitcoinAddress; + LitecoinAddress: LitecoinAddress; + DogecoinAddress: DogecoinAddress; + MonacoinAddress: MonacoinAddress; + RootstockAddress: RootstockAddress; + RippleAddress: RippleAddress; + BitcoinCashAddress: BitcoinCashAddress; + BinanceAddress: BinanceAddress; SolanaAddress: SolanaAddress; Hex: Hex; ChainId: ChainId; From 2c0f7146dcfe1a73ebb0250829276b59ab8d4f46 Mon Sep 17 00:00:00 2001 From: sevenzing Date: Mon, 1 Jun 2026 12:54:51 +0300 Subject: [PATCH 03/19] pnpm generate --- .../src/omnigraph/generated/introspection.ts | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/packages/enssdk/src/omnigraph/generated/introspection.ts b/packages/enssdk/src/omnigraph/generated/introspection.ts index 87bd3a9049..2f0b8c2d95 100644 --- a/packages/enssdk/src/omnigraph/generated/introspection.ts +++ b/packages/enssdk/src/omnigraph/generated/introspection.ts @@ -1093,10 +1093,18 @@ const introspection = { "kind": "SCALAR", "name": "BigInt" }, + { + "kind": "SCALAR", + "name": "BinanceAddress" + }, { "kind": "SCALAR", "name": "BitcoinAddress" }, + { + "kind": "SCALAR", + "name": "BitcoinCashAddress" + }, { "kind": "SCALAR", "name": "Boolean" @@ -1174,6 +1182,10 @@ const introspection = { "kind": "SCALAR", "name": "CoinType" }, + { + "kind": "SCALAR", + "name": "DogecoinAddress" + }, { "kind": "INTERFACE", "name": "Domain", @@ -1678,6 +1690,15 @@ const introspection = { "args": [], "isDeprecated": false }, + { + "name": "email", + "type": { + "kind": "SCALAR", + "name": "String" + }, + "args": [], + "isDeprecated": false + }, { "name": "header", "type": { @@ -3898,6 +3919,14 @@ const introspection = { ], "interfaces": [] }, + { + "kind": "SCALAR", + "name": "LitecoinAddress" + }, + { + "kind": "SCALAR", + "name": "MonacoinAddress" + }, { "kind": "INPUT_OBJECT", "name": "NameOrNodeInput", @@ -4956,6 +4985,15 @@ const introspection = { "args": [], "isDeprecated": false }, + { + "name": "binance", + "type": { + "kind": "SCALAR", + "name": "BinanceAddress" + }, + "args": [], + "isDeprecated": false + }, { "name": "bitcoin", "type": { @@ -4965,6 +5003,24 @@ const introspection = { "args": [], "isDeprecated": false }, + { + "name": "bitcoincash", + "type": { + "kind": "SCALAR", + "name": "BitcoinCashAddress" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "dogecoin", + "type": { + "kind": "SCALAR", + "name": "DogecoinAddress" + }, + "args": [], + "isDeprecated": false + }, { "name": "ethereum", "type": { @@ -4974,6 +5030,42 @@ const introspection = { "args": [], "isDeprecated": false }, + { + "name": "litecoin", + "type": { + "kind": "SCALAR", + "name": "LitecoinAddress" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "monacoin", + "type": { + "kind": "SCALAR", + "name": "MonacoinAddress" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "ripple", + "type": { + "kind": "SCALAR", + "name": "RippleAddress" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "rootstock", + "type": { + "kind": "SCALAR", + "name": "RootstockAddress" + }, + "args": [], + "isDeprecated": false + }, { "name": "solana", "type": { @@ -5062,6 +5154,24 @@ const introspection = { "args": [], "isDeprecated": false }, + { + "name": "keybase", + "type": { + "kind": "OBJECT", + "name": "ProfileSocialAccount" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "linkedin", + "type": { + "kind": "OBJECT", + "name": "ProfileSocialAccount" + }, + "args": [], + "isDeprecated": false + }, { "name": "telegram", "type": { @@ -7010,6 +7120,14 @@ const introspection = { ], "interfaces": [] }, + { + "kind": "SCALAR", + "name": "RippleAddress" + }, + { + "kind": "SCALAR", + "name": "RootstockAddress" + }, { "kind": "SCALAR", "name": "SolanaAddress" From d09bb0b104b90565ca2e3ba9045a852f1099ba04 Mon Sep 17 00:00:00 2001 From: sevenzing Date: Tue, 2 Jun 2026 14:59:23 +0300 Subject: [PATCH 04/19] add more tests and small fixes --- .../resolve-records.integration.test.ts | 32 ++++---- apps/ensapi/src/omnigraph-api/builder.ts | 2 + .../lib/resolution/profile/README.md | 7 +- .../profile/build-profile-selection.test.ts | 12 ++- .../lib/resolution/profile/parsers/images.ts | 5 -- .../lib/resolution/profile/parsers/index.ts | 1 - .../resolution/profile/parsers/social.test.ts | 52 +++++++++--- .../lib/resolution/profile/parsers/social.ts | 47 +++++------ .../resolution/profile/parsers/texts.test.ts | 19 +++++ .../lib/resolution/profile/parsers/texts.ts | 39 ++++++++- .../schema/account.integration.test.ts | 46 ++++++++++- .../schema/domain.integration.test.ts | 80 ++++++++++++++----- .../src/omnigraph-api/schema/profile.ts | 8 +- .../src/omnigraph-api/schema/scalars.ts | 8 ++ packages/datasources/package.json | 3 +- packages/datasources/src/devnet/constants.ts | 46 ++++++++++- .../src/omnigraph-api/example-queries.ts | 19 +++++ .../ensnode-sdk/src/shared/zod-schemas.ts | 11 +++ packages/enssdk/src/lib/types/email.ts | 4 + packages/enssdk/src/lib/types/index.ts | 1 + .../src/omnigraph/generated/introspection.ts | 6 +- .../src/omnigraph/generated/schema.graphql | 7 +- packages/enssdk/src/omnigraph/graphql.ts | 2 + .../src/seed/resolver-records.ts | 17 ++-- pnpm-lock.yaml | 13 +-- 25 files changed, 372 insertions(+), 115 deletions(-) create mode 100644 packages/enssdk/src/lib/types/email.ts diff --git a/apps/ensapi/src/handlers/api/resolution/resolve-records.integration.test.ts b/apps/ensapi/src/handlers/api/resolution/resolve-records.integration.test.ts index 5d49aa8682..e7e6adf8b3 100644 --- a/apps/ensapi/src/handlers/api/resolution/resolve-records.integration.test.ts +++ b/apps/ensapi/src/handlers/api/resolution/resolve-records.integration.test.ts @@ -6,7 +6,7 @@ import { describe, expect, it } from "vitest"; -import { accounts, addresses, fixtures } from "@ensnode/datasources/devnet"; +import { accounts, addresses, fixtures, testEthTextRecords } from "@ensnode/datasources/devnet"; const BASE_URL = process.env.ENSNODE_URL!; @@ -60,7 +60,9 @@ describe("GET /api/resolve/records/:name", () => { expected: { status: 200, body: { - records: { texts: { description: "test.eth" } }, + records: { + texts: { [testEthTextRecords.description.key]: testEthTextRecords.description.value }, + }, accelerationRequested: false, accelerationAttempted: false, }, @@ -157,7 +159,7 @@ describe("GET /api/resolve/records/:name", () => { expected: { status: 200, body: { - records: { texts: { avatar: "https://example.com/avatar.png" } }, + records: { texts: { [testEthTextRecords.avatar.key]: testEthTextRecords.avatar.value } }, accelerationRequested: false, accelerationAttempted: false, }, @@ -187,8 +189,8 @@ describe("GET /api/resolve/records/:name", () => { records: { addresses: { 60: accounts.owner.address, - 0: fixtures.bitcoinAddress, - 2: fixtures.litecoinAddress, + 0: fixtures.rawAddresses.bitcoin.raw, + 2: fixtures.rawAddresses.litecoin.raw, 777777: null, }, }, @@ -204,7 +206,7 @@ describe("GET /api/resolve/records/:name", () => { query: [ "name=true", "addresses=60,0,2", - "texts=avatar,description,url,email,com.twitter,com.github", + "texts=avatar,description,url,email,com.twitter,com.github,com.x,org.telegram", "contenthash=true", "pubkey=true", "version=true", @@ -217,16 +219,18 @@ describe("GET /api/resolve/records/:name", () => { records: { addresses: { 60: accounts.owner.address, - 0: fixtures.bitcoinAddress, - 2: fixtures.litecoinAddress, + 0: fixtures.rawAddresses.bitcoin.raw, + 2: fixtures.rawAddresses.litecoin.raw, }, texts: { - avatar: "https://example.com/avatar.png", - description: "test.eth", - url: "https://ens.domains", - email: "test@ens.domains", - "com.twitter": "ensdomains", - "com.github": "ensdomains", + [testEthTextRecords.avatar.key]: testEthTextRecords.avatar.value, + [testEthTextRecords.description.key]: testEthTextRecords.description.value, + [testEthTextRecords.url.key]: testEthTextRecords.url.value, + [testEthTextRecords.email.key]: testEthTextRecords.email.value, + [testEthTextRecords.twitter.key]: testEthTextRecords.twitter.value, + [testEthTextRecords.github.key]: testEthTextRecords.github.value, + [testEthTextRecords.x.key]: testEthTextRecords.x.value, + [testEthTextRecords.telegram.key]: testEthTextRecords.telegram.value, }, contenthash: fixtures.contenthash, pubkey: { diff --git a/apps/ensapi/src/omnigraph-api/builder.ts b/apps/ensapi/src/omnigraph-api/builder.ts index 0b064624f1..3dcbf13d51 100644 --- a/apps/ensapi/src/omnigraph-api/builder.ts +++ b/apps/ensapi/src/omnigraph-api/builder.ts @@ -15,6 +15,7 @@ import type { CoinType, DogecoinAddress, DomainId, + Email, Hex, InterfaceId, InterpretedLabel, @@ -74,6 +75,7 @@ export type BuilderScalars = { BigInt: { Input: bigint; Output: bigint }; JSON: { Input: JsonValue; Output: JsonValue }; Address: { Input: NormalizedAddress; Output: NormalizedAddress }; + Email: { Input: Email; Output: Email }; BitcoinAddress: { Input: BitcoinAddress; Output: BitcoinAddress }; LitecoinAddress: { Input: LitecoinAddress; Output: LitecoinAddress }; DogecoinAddress: { Input: DogecoinAddress; Output: DogecoinAddress }; diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/README.md b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/README.md index 87bbebdd21..dbdc82631e 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/README.md +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/README.md @@ -36,14 +36,15 @@ Record names use a `texts.` prefix for ENS text records and `addresses.` prefix for ENS text records and `addresses.][]` | 📋 | [25](https://docs.ens.domains/ensip/25) | Non-empty attestation linking an ENS name to an on-chain AI agent registry entry. | diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/build-profile-selection.test.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/build-profile-selection.test.ts index 3de0a6f7ac..b1401c1296 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/build-profile-selection.test.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/build-profile-selection.test.ts @@ -61,7 +61,7 @@ describe("buildProfileSelectionFromResolveContainerInfo", () => { [ "socials.twitter", "profile { socials { twitter { handle httpUrl } } }", - { texts: ["com.twitter", "vnd.twitter"] }, + { texts: ["com.x", "com.twitter", "vnd.twitter"] }, ], [ "socials.telegram", @@ -89,7 +89,15 @@ describe("buildProfileSelectionFromResolveContainerInfo", () => { } `, { - texts: ["description", "avatar", "com.github", "vnd.github", "com.twitter", "vnd.twitter"], + texts: [ + "description", + "avatar", + "com.github", + "vnd.github", + "com.x", + "com.twitter", + "vnd.twitter", + ], addresses: [60, 0], }, ], diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/images.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/images.ts index b148bbffdc..59d8baffe5 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/images.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/images.ts @@ -58,11 +58,6 @@ function interpretProfileImageHttpUrl( return getEnsMetadataServiceImageUrl(model.id, di.context.namespace, record)?.href ?? null; } -/** Returns the raw website record when set; callers expose it as `httpUrl`. */ -export function interpretProfileWebsiteHttpUrl(rawValue: string | null | undefined): string | null { - return rawValue ?? null; -} - export const profileImageHttpUrlDescription = (recordLabel: "avatar" | "header") => `Provides a HTTP-compatible URL for fetching the ${recordLabel} image that can be safely referenced as an image in web browsers. ` + `This is an abstraction over the "raw" ${recordLabel} record, which may reference non-HTTP compatible URLs or encodings including IPFS urls, CAIP-22 / CAIP-29 NFT References, and more edge cases that cannot be trivially referenced as an image in most web browsers. ` + diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/index.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/index.ts index 49c8376cb9..1e8476a7bd 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/index.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/index.ts @@ -14,7 +14,6 @@ export { } from "./addresses"; export type { ProfileImageResult } from "./images"; export { - interpretProfileWebsiteHttpUrl, ProfileAvatarParser, ProfileHeaderParser, profileImageHttpUrlDescription, diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/social.test.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/social.test.ts index e7ab70f072..fc8b32774b 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/social.test.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/social.test.ts @@ -107,7 +107,9 @@ describe("SocialGithubParser", () => { describe("SocialTwitterParser", () => { it("has correct selection", () => { - expect(SocialTwitterParser.selection).toEqual({ texts: ["com.twitter", "vnd.twitter"] }); + expect(SocialTwitterParser.selection).toEqual({ + texts: ["com.x", "com.twitter", "vnd.twitter"], + }); }); it.each([ @@ -144,16 +146,14 @@ describe("SocialTwitterParser", () => { { handle: "itslevchiks", httpUrl: "https://x.com/itslevchiks?lang=en" }, ], ])("parses %s", (_message, input, expected) => { - expect(SocialTwitterParser.parse(profileRecordsModel({ "com.twitter": input }))).toEqual( - expected, - ); + expect(SocialTwitterParser.parse(profileRecordsModel({ "com.x": input }))).toEqual(expected); }); it.each([ ["record unset", {}], - ["empty string", { "com.twitter": "" }], - ["invalid handle characters", { "com.twitter": "hello world" }], - ["foreign social URL", { "com.twitter": "https://github.com/itslevchiks" }], + ["empty string", { "com.x": "" }], + ["invalid handle characters", { "com.x": "hello world" }], + ["foreign social URL", { "com.x": "https://github.com/itslevchiks" }], ])("returns null: %s", (_message, texts) => { expect(SocialTwitterParser.parse(profileRecordsModel(texts))).toBeNull(); }); @@ -237,12 +237,34 @@ describe("SocialGithubParser (vnd.github fallback)", () => { }); }); -describe("SocialTwitterParser (vnd.twitter fallback)", () => { - it("has correct selection (includes vnd.twitter)", () => { - expect(SocialTwitterParser.selection).toEqual({ texts: ["com.twitter", "vnd.twitter"] }); +describe("SocialTwitterParser (text key fallbacks)", () => { + it("has correct selection (includes com.twitter and vnd.twitter)", () => { + expect(SocialTwitterParser.selection).toEqual({ + texts: ["com.x", "com.twitter", "vnd.twitter"], + }); + }); + + it("falls back to com.twitter when com.x is unset", () => { + expect( + SocialTwitterParser.parse(profileRecordsModel({ "com.twitter": "itslevchiks" })), + ).toEqual({ handle: "itslevchiks", httpUrl: "https://x.com/itslevchiks" }); + }); + + it("prefers com.x over com.twitter", () => { + expect( + SocialTwitterParser.parse( + profileRecordsModel({ "com.x": "primaryuser", "com.twitter": "legacyuser" }), + ), + ).toEqual({ handle: "primaryuser", httpUrl: "https://x.com/primaryuser" }); + }); + + it("falls back to com.twitter when com.x is empty", () => { + expect( + SocialTwitterParser.parse(profileRecordsModel({ "com.x": "", "com.twitter": "legacyuser" })), + ).toEqual({ handle: "legacyuser", httpUrl: "https://x.com/legacyuser" }); }); - it("falls back to vnd.twitter when com.twitter is unset", () => { + it("falls back to vnd.twitter when com.x and com.twitter are unset", () => { expect( SocialTwitterParser.parse(profileRecordsModel({ "vnd.twitter": "itslevchiks" })), ).toEqual({ handle: "itslevchiks", httpUrl: "https://x.com/itslevchiks" }); @@ -255,6 +277,14 @@ describe("SocialTwitterParser (vnd.twitter fallback)", () => { ), ).toEqual({ handle: "primaryuser", httpUrl: "https://x.com/primaryuser" }); }); + + it("falls back to vnd.twitter when com.x and com.twitter are empty", () => { + expect( + SocialTwitterParser.parse( + profileRecordsModel({ "com.x": "", "com.twitter": "", "vnd.twitter": "legacyuser" }), + ), + ).toEqual({ handle: "legacyuser", httpUrl: "https://x.com/legacyuser" }); + }); }); describe("SocialLinkedInParser", () => { diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/social.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/social.ts index 72396d1caf..06836d3606 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/social.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/social.ts @@ -14,14 +14,12 @@ export type ParseSocialHandleOptions = { baseUrl: string; /** Regex pattern the extracted handle must match. */ handlePattern: RegExp; - /** - * Number of leading path segments to skip when extracting the handle from a URL. - * Use when the profile handle lives at a sub-path (e.g. `/in/` on LinkedIn → 1). - * Defaults to 0. - */ - pathOffset?: number; }; +function pathOffsetFromBaseUrl(baseUrl: string): number { + return new URL(baseUrl).pathname.split("/").filter((s) => s.length > 0).length; +} + /** * Normalizes a social handle from a raw ENS text record value. * @@ -41,8 +39,8 @@ export function parseSocialHandle({ hostnames, baseUrl, handlePattern, - pathOffset = 0, }: ParseSocialHandleOptions): SocialHandleResult | null { + const pathOffset = pathOffsetFromBaseUrl(baseUrl); const raw = value?.trim(); if (!raw) return null; @@ -80,37 +78,37 @@ export function parseSocialHandle({ } const socialParser = ( - textKey: string, + textKeys: string | readonly string[], hostnames: readonly string[], baseUrl: string, handlePattern: RegExp, - options?: { fallbackTextKey?: string; pathOffset?: number }, -): ProfileFieldParser => ({ - selection: { - texts: options?.fallbackTextKey ? [textKey, options.fallbackTextKey] : [textKey], - }, - parse: (records) => { - const opts = { hostnames, baseUrl, handlePattern, pathOffset: options?.pathOffset }; - const primary = parseSocialHandle({ value: records.texts?.[textKey], ...opts }); - if (primary !== null || options?.fallbackTextKey === undefined) return primary; - return parseSocialHandle({ value: records.texts?.[options.fallbackTextKey], ...opts }); - }, -}); +): ProfileFieldParser => { + const keys = typeof textKeys === "string" ? [textKeys] : [...textKeys]; + const opts = { hostnames, baseUrl, handlePattern }; + return { + selection: { texts: keys }, + parse: (records) => { + for (const key of keys) { + const result = parseSocialHandle({ value: records.texts?.[key], ...opts }); + if (result !== null) return result; + } + return null; + }, + }; +}; export const SocialGithubParser: ProfileFieldParser = socialParser( - "com.github", + ["com.github", "vnd.github"], ["github.com", "www.github.com"], "https://github.com", /^[A-Za-z0-9_./-]+$/, - { fallbackTextKey: "vnd.github" }, ); export const SocialTwitterParser: ProfileFieldParser = socialParser( - "com.twitter", + ["com.x", "com.twitter", "vnd.twitter"], ["twitter.com", "www.twitter.com", "x.com", "www.x.com"], "https://x.com", /^[A-Za-z0-9_]+$/, - { fallbackTextKey: "vnd.twitter" }, ); export const SocialTelegramParser: ProfileFieldParser = socialParser( @@ -125,7 +123,6 @@ export const SocialLinkedInParser: ProfileFieldParser = soci ["linkedin.com", "www.linkedin.com"], "https://www.linkedin.com/in", /^[A-Za-z0-9_-]+$/, - { pathOffset: 1 }, ); export const SocialKeybaseParser: ProfileFieldParser = socialParser( diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/texts.test.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/texts.test.ts index 27c0a32630..0cdc446d6f 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/texts.test.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/texts.test.ts @@ -38,9 +38,18 @@ describe("ProfileWebsiteParser", () => { it.each([ ["record unset", {}], ["empty string", { url: "" }], + ["whitespace only", { url: " " }], + ["non-http scheme", { url: "ipfs://example.com" }], + ["not a URL", { url: "not-a-url" }], ])("returns null: %s", (_message, texts) => { expect(ProfileWebsiteParser.parse(profileRecordsModel(texts))).toBeNull(); }); + + it("trims surrounding whitespace before parsing", () => { + expect( + ProfileWebsiteParser.parse(profileRecordsModel({ url: " https://example.com " })), + ).toBe("https://example.com"); + }); }); describe("ProfileEmailParser", () => { @@ -58,7 +67,17 @@ describe("ProfileEmailParser", () => { it.each([ ["record unset", {}], ["empty string", { email: "" }], + ["whitespace only", { email: " " }], + ["missing @", { email: "userexample.com" }], + ["missing domain", { email: "user@" }], + ["spaces inside", { email: "user @example.com" }], ])("returns null: %s", (_message, texts) => { expect(ProfileEmailParser.parse(profileRecordsModel(texts))).toBeNull(); }); + + it("trims surrounding whitespace from valid email", () => { + expect(ProfileEmailParser.parse(profileRecordsModel({ email: " user@example.com " }))).toBe( + "user@example.com", + ); + }); }); diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/texts.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/texts.ts index 13556d192b..52c376a320 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/texts.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/texts.ts @@ -1,5 +1,11 @@ +import type { Email } from "enssdk"; + +import { makeEmailSchema } from "@ensnode/ensnode-sdk/internal"; + import type { ProfileFieldParser } from "./types"; +const profileEmailSchema = makeEmailSchema("email text record"); + const textParser = (key: string): ProfileFieldParser => ({ selection: { texts: [key] }, parse: (records) => { @@ -10,5 +16,34 @@ const textParser = (key: string): ProfileFieldParser => ({ }); export const ProfileDescriptionParser: ProfileFieldParser = textParser("description"); -export const ProfileEmailParser: ProfileFieldParser = textParser("email"); -export const ProfileWebsiteParser: ProfileFieldParser = textParser("url"); + +export const ProfileEmailParser: ProfileFieldParser = { + selection: { texts: ["email"] }, + parse: (records) => { + const raw = records.texts?.email; + if (raw == null || raw === "") return null; + const parsed = profileEmailSchema.safeParse(raw); + return parsed.success ? parsed.data : null; + }, +}; + +const urlParser = (key: string): ProfileFieldParser => ({ + selection: { texts: [key] }, + parse: (records) => { + const raw = records.texts?.[key]; + if (raw == null || raw === "") return null; + const trimmed = raw.trim(); + if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) { + return null; + } + + try { + new URL(trimmed); + return trimmed; + } catch { + return null; + } + }, +}); + +export const ProfileWebsiteParser: ProfileFieldParser = urlParser("url"); diff --git a/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts b/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts index f34d7f4a24..b63a0412ec 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts @@ -2,7 +2,7 @@ import { ETH_COIN_TYPE, evmChainIdToCoinType, type Hex, type InterpretedName } f import { base } from "viem/chains"; import { beforeAll, describe, expect, it } from "vitest"; -import { accounts } from "@ensnode/datasources/devnet"; +import { accounts, testEthTextRecords } from "@ensnode/datasources/devnet"; import { AccountDomainsPaginated, @@ -328,6 +328,10 @@ describe("Account.primaryName and Account.primaryNames", () => { name: CanonicalNameResult; resolve?: { records?: { addresses: Array<{ coinType: number; address: Hex | null }> } | null; + profile?: { + description: string | null; + avatar: { httpUrl: string | null } | null; + } | null; } | null; }; @@ -444,6 +448,24 @@ describe("Account.primaryName and Account.primaryNames", () => { } `; + const AccountPrimaryNameChainedProfile = gql` + query AccountPrimaryNameChainedProfile($address: Address!) { + account(by: { address: $address }) { + resolve { + primaryName(by: { coinType: 60 }) { + name { interpreted beautified } + resolve { + profile { + description + avatar { httpUrl } + } + } + } + } + } + } + `; + it("resolves primary name by coinType for owner on Ethereum", async () => { await expect( request(AccountPrimaryNameByCoinType, { @@ -565,6 +587,28 @@ describe("Account.primaryName and Account.primaryNames", () => { }); }); + it("chains forward resolution through primaryName.profile", async () => { + await expect( + request(AccountPrimaryNameChainedProfile, { + address: accounts.owner.address, + }), + ).resolves.toMatchObject({ + account: { + resolve: { + primaryName: { + name: TEST_ETH_NAME, + resolve: { + profile: { + description: testEthTextRecords.description.value, + avatar: { httpUrl: testEthTextRecords.avatar.value }, + }, + }, + }, + }, + }, + }); + }); + it("rejects empty coinTypes at GraphQL validation", async () => { await expect( request(AccountPrimaryNamesByCoinTypes, { diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts b/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts index 02e3a04432..d338e2891d 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts @@ -20,7 +20,7 @@ import { import { beforeAll, describe, expect, it } from "vitest"; import { DatasourceNames } from "@ensnode/datasources"; -import { accounts, addresses, fixtures } from "@ensnode/datasources/devnet"; +import { accounts, addresses, fixtures, testEthTextRecords } from "@ensnode/datasources/devnet"; import { getDatasourceContract } from "@ensnode/ensnode-sdk"; import { DEVNET_ETH_LABELS, DEVNET_NAMES } from "@/test/integration/devnet-names"; @@ -560,9 +560,6 @@ describe("Domain.records", () => { } `; - const BITCOIN_COIN_TYPE = 0; - const LITECOIN_COIN_TYPE = 2; - it("resolves address and text records for example.eth", async () => { await expect( request(DomainRecords, { @@ -587,7 +584,16 @@ describe("Domain.records", () => { request(DomainRecordsAll, { name: "test.eth", addresses: [ETH_COIN_TYPE, 0, 2], - texts: ["avatar", "description", "url", "email", "com.twitter", "com.github"], + texts: [ + testEthTextRecords.avatar.key, + testEthTextRecords.description.key, + testEthTextRecords.url.key, + testEthTextRecords.email.key, + testEthTextRecords.twitter.key, + testEthTextRecords.github.key, + testEthTextRecords.x.key, + testEthTextRecords.telegram.key, + ], // BigInt GraphQL vars must be strings here — JSON.stringify (used by the test client) cannot serialize bigint contentTypeMask: "1", interfaceIds: [fixtures.fourBytesInterface], @@ -604,16 +610,24 @@ describe("Domain.records", () => { interfaces: [{ interfaceId: fixtures.fourBytesInterface, implementer: addresses.one }], addresses: [ { coinType: ETH_COIN_TYPE, address: accounts.owner.address }, - { coinType: BITCOIN_COIN_TYPE, address: fixtures.bitcoinAddress }, - { coinType: LITECOIN_COIN_TYPE, address: fixtures.litecoinAddress }, + { + coinType: fixtures.rawAddresses.bitcoin.coinType, + address: fixtures.rawAddresses.bitcoin.raw, + }, + { + coinType: fixtures.rawAddresses.litecoin.coinType, + address: fixtures.rawAddresses.litecoin.raw, + }, ], texts: [ - { key: "avatar", value: "https://example.com/avatar.png" }, - { key: "description", value: "test.eth" }, - { key: "url", value: "https://ens.domains" }, - { key: "email", value: "test@ens.domains" }, - { key: "com.twitter", value: "ensdomains" }, - { key: "com.github", value: "ensdomains" }, + testEthTextRecords.avatar, + testEthTextRecords.description, + testEthTextRecords.url, + testEthTextRecords.email, + testEthTextRecords.twitter, + testEthTextRecords.github, + testEthTextRecords.x, + testEthTextRecords.telegram, ], }, }, @@ -702,8 +716,15 @@ describe("Domain.profile", () => { profile { description avatar { httpUrl } - addresses { ethereum } - socials { github { handle httpUrl } } + header { httpUrl } + website { httpUrl } + email + addresses { ethereum bitcoin litecoin solana } + socials { + github { httpUrl handle } + twitter { httpUrl handle } + telegram { httpUrl handle } + } } } } @@ -717,10 +738,31 @@ describe("Domain.profile", () => { domain: { resolve: { profile: { - description: "test.eth", - avatar: { httpUrl: "https://example.com/avatar.png" }, - addresses: { ethereum: accounts.owner.address }, - socials: { github: { handle: "ensdomains", httpUrl: "https://github.com/ensdomains" } }, + description: testEthTextRecords.description.value, + avatar: { httpUrl: testEthTextRecords.avatar.value }, + header: { httpUrl: testEthTextRecords.header.value }, + website: { httpUrl: testEthTextRecords.url.value }, + email: testEthTextRecords.email.value, + addresses: { + ethereum: accounts.owner.address, + bitcoin: fixtures.rawAddresses.bitcoin.address, + litecoin: fixtures.rawAddresses.litecoin.address, + solana: fixtures.rawAddresses.solana.address, + }, + socials: { + github: { + handle: "ensdomains", + httpUrl: "https://github.com/ensdomains", + }, + telegram: { + handle: "ensdomains", + httpUrl: "https://t.me/ensdomains", + }, + twitter: { + handle: "this_is_real_ensdomains_not_twitter_but_x_haha", + httpUrl: "https://x.com/this_is_real_ensdomains_not_twitter_but_x_haha", + }, + }, }, }, }, diff --git a/apps/ensapi/src/omnigraph-api/schema/profile.ts b/apps/ensapi/src/omnigraph-api/schema/profile.ts index 5f231faf85..8fa313cc4e 100644 --- a/apps/ensapi/src/omnigraph-api/schema/profile.ts +++ b/apps/ensapi/src/omnigraph-api/schema/profile.ts @@ -1,7 +1,6 @@ import { builder } from "@/omnigraph-api/builder"; import { ADDRESS_PARSERS, - interpretProfileWebsiteHttpUrl, ProfileAvatarParser, ProfileDescriptionParser, ProfileEmailParser, @@ -171,7 +170,7 @@ ProfileWebsiteRef.implement({ httpUrl: t.string({ description: "The HTTP-compatible website URL, or null when unset.", nullable: true, - resolve: (model) => interpretProfileWebsiteHttpUrl(ProfileWebsiteParser.parse(model)), + resolve: (model) => ProfileWebsiteParser.parse(model), }), }), }); @@ -201,8 +200,9 @@ DomainProfileRef.implement({ nullable: true, resolve: (model) => ProfileDescriptionParser.parse(model), }), - email: t.string({ - description: "The contact email address, or null when unset.", + email: t.field({ + description: "The contact email address, or null when unset or invalid.", + type: "Email", nullable: true, resolve: (model) => ProfileEmailParser.parse(model), }), diff --git a/apps/ensapi/src/omnigraph-api/schema/scalars.ts b/apps/ensapi/src/omnigraph-api/schema/scalars.ts index 1e1271e329..96b871cf0b 100644 --- a/apps/ensapi/src/omnigraph-api/schema/scalars.ts +++ b/apps/ensapi/src/omnigraph-api/schema/scalars.ts @@ -9,6 +9,7 @@ import { type CoinType, type DogecoinAddress, type DomainId, + type Email, type Hex, type InterfaceId, type InterpretedLabel, @@ -40,6 +41,7 @@ import { z } from "zod/v4"; import { makeChainIdSchema, makeCoinTypeSchema, + makeEmailSchema, makeNormalizedAddressSchema, } from "@ensnode/ensnode-sdk/internal"; @@ -63,6 +65,12 @@ builder.scalarType("Address", { parseValue: (value) => makeNormalizedAddressSchema("Address").parse(value), }); +builder.scalarType("Email", { + description: "Email represents a validated contact email address.", + serialize: (value: Email) => value, + parseValue: (value) => makeEmailSchema("Email").parse(value), +}); + const makeCoinAddressSchema = (label: string, coinType: number) => z.coerce.string().check((ctx) => { try { diff --git a/packages/datasources/package.json b/packages/datasources/package.json index fe59daabc5..1afb5d193d 100644 --- a/packages/datasources/package.json +++ b/packages/datasources/package.json @@ -57,6 +57,7 @@ }, "dependencies": { "@ponder/utils": "^0.2.18", - "enssdk": "workspace:*" + "enssdk": "workspace:*", + "@ensdomains/address-encoder": "^1.1.2" } } diff --git a/packages/datasources/src/devnet/constants.ts b/packages/datasources/src/devnet/constants.ts index e1c942f07f..aa75dd355f 100644 --- a/packages/datasources/src/devnet/constants.ts +++ b/packages/datasources/src/devnet/constants.ts @@ -1,4 +1,6 @@ -import type { NormalizedAddress } from "enssdk"; +import { type CoinName, coinNameToTypeMap, getCoderByCoinName } from "@ensdomains/address-encoder"; +import { bytesToHex } from "@ensdomains/address-encoder/utils"; +import type { CoinType, NormalizedAddress } from "enssdk"; import { asNormalizedAddress, toNormalizedAddress } from "enssdk"; import type { Hex } from "viem"; import { mnemonicToAccount } from "viem/accounts"; @@ -129,12 +131,48 @@ export const addresses = { one: asNormalizedAddress(`0x${"1".repeat(40)}`), } as const satisfies Record; +const getRawAddress = (coinName: CoinName, address: string) => { + const coder = getCoderByCoinName(coinName); + return { + coinType: coder.coinType, + raw: bytesToHex(coder.decode(address)), + address, + }; +}; + +/** + * Text records seeded on `test.eth` (PermissionedResolver) in the ens-test-env devnet. + * @see packages/integration-test-env/src/seed/resolver-records.ts + */ +export const testEthTextRecords = { + avatar: { key: "avatar", value: "https://example.com/avatar.png" }, + twitter: { key: "com.twitter", value: "ensdomains" }, + github: { key: "com.github", value: "@ensdomains" }, + x: { key: "com.x", value: "this_is_real_ensdomains_not_twitter_but_x_haha" }, + telegram: { key: "org.telegram", value: "t.me/ensdomains" }, + url: { key: "url", value: "https://ens.domains" }, + email: { key: "email", value: "test@ens.domains" }, + description: { key: "description", value: "test.eth" }, + header: { key: "header", value: "https://example.com/header.png" }, +} as const; + +const rawAddresses = { + bitcoin: getRawAddress("btc", "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"), + litecoin: getRawAddress("ltc", "LaMT348PWRnrqeeWArpwQPbuanpXDZGEUz"), + dogecoin: getRawAddress("doge", "DBXu2kgc3xtvCUWFcxFE3r9hEYgmuaaCyD"), + monacoin: getRawAddress("mona", "MHxgS2XMXjeJ4if2PRRbWYcdwZPWfdwaDT"), + rootstock: getRawAddress("rbtc", "0x5aaEB6053f3e94c9b9a09f33669435E7ef1bEAeD"), + binance: getRawAddress("bnb", "bnb1grpf0955h0ykzq3ar5nmum7y6gdfl6lxfn46h2"), + solana: getRawAddress("sol", "FncazAs6omJJjtLVzquzT9KoyXn6tFixr9kGjr42ktLj"), +} as const satisfies Record; + export const fixtures = { abiBytes: `0x${"01".repeat(32)}`, fourBytesInterface: "0x11100111", publicKeyX: `0x${"02".repeat(32)}`, publicKeyY: `0x${"03".repeat(32)}`, contenthash: `0x${"04".repeat(32)}`, - bitcoinAddress: `0x${"05".repeat(25)}`, - litecoinAddress: `0x${"06".repeat(25)}`, -} as const satisfies Record; + + rawAddresses: rawAddresses, + textRecords: testEthTextRecords, +} as const; diff --git a/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts b/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts index de2675e9e3..fce4501581 100644 --- a/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts +++ b/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts @@ -624,6 +624,25 @@ query GetEthDomains { }`, variables: { default: {} }, }, + { + id: "domain-profile", + query: ` +query DomainProfile($name: InterpretedName!) { + domain(by: { name: $name }) { + resolve { + profile { + description + avatar { httpUrl } + addresses { ethereum } + socials { github { handle httpUrl } } + website { httpUrl } + email + } + } + } +}`, + variables: { default: { name: "vitalik.eth" } }, + }, ]; const graphqlApiExampleQueryById = new Map( diff --git a/packages/ensnode-sdk/src/shared/zod-schemas.ts b/packages/ensnode-sdk/src/shared/zod-schemas.ts index ea21e504ed..735035cacd 100644 --- a/packages/ensnode-sdk/src/shared/zod-schemas.ts +++ b/packages/ensnode-sdk/src/shared/zod-schemas.ts @@ -6,6 +6,7 @@ import type { ChainId, DefaultableChainId, Duration, + Email, Hex, InterpretedName, Node, @@ -153,6 +154,16 @@ export const makeCoinTypeStringSchema = (valueLabel: string = "Coin Type String" /** * Parses a serialized representation of an EVM address into a {@link NormalizedAddress}. */ +/** + * Parses a string into a validated {@link Email} (trimmed). + */ +export const makeEmailSchema = (valueLabel: string = "Email") => + z + .string({ error: `${valueLabel} must be a string.` }) + .trim() + .pipe(z.email({ error: `${valueLabel} must be a valid email address.` })) + .transform((value) => value as Email); + export const makeNormalizedAddressSchema = (valueLabel: string = "EVM address") => z .string() diff --git a/packages/enssdk/src/lib/types/email.ts b/packages/enssdk/src/lib/types/email.ts new file mode 100644 index 0000000000..f6b80846d9 --- /dev/null +++ b/packages/enssdk/src/lib/types/email.ts @@ -0,0 +1,4 @@ +/** + * A contact email address normalized by trimming and validated as a well-formed email. + */ +export type Email = string; diff --git a/packages/enssdk/src/lib/types/index.ts b/packages/enssdk/src/lib/types/index.ts index 9a9cb7015f..4e3f59da04 100644 --- a/packages/enssdk/src/lib/types/index.ts +++ b/packages/enssdk/src/lib/types/index.ts @@ -1,6 +1,7 @@ export * from "./addresses"; export * from "./coin-type"; export * from "./eac"; +export * from "./email"; export * from "./ens"; export * from "./ensv2"; export * from "./evm"; diff --git a/packages/enssdk/src/omnigraph/generated/introspection.ts b/packages/enssdk/src/omnigraph/generated/introspection.ts index eb2c1bc2a7..37377f6f86 100644 --- a/packages/enssdk/src/omnigraph/generated/introspection.ts +++ b/packages/enssdk/src/omnigraph/generated/introspection.ts @@ -1702,7 +1702,7 @@ const introspection = { "name": "email", "type": { "kind": "SCALAR", - "name": "String" + "name": "Email" }, "args": [], "isDeprecated": false @@ -3481,6 +3481,10 @@ const introspection = { } ] }, + { + "kind": "SCALAR", + "name": "Email" + }, { "kind": "OBJECT", "name": "Event", diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index 051785a657..4391d31e19 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -421,8 +421,8 @@ type DomainProfile { """The profile description, or null when unset.""" description: String - """The contact email address, or null when unset.""" - email: String + """The contact email address, or null when unset or invalid.""" + email: Email header: ProfileHeader socials: ProfileSocials website: ProfileWebsite @@ -820,6 +820,9 @@ type ENSv2RegistryReservation implements Registration { unregistrant: Account } +"""Email represents a validated contact email address.""" +scalar Email + """ An Event represents a discrete Log Event that was emitted on an EVM chain, including associated metadata. """ diff --git a/packages/enssdk/src/omnigraph/graphql.ts b/packages/enssdk/src/omnigraph/graphql.ts index 229be7e888..4373955af5 100644 --- a/packages/enssdk/src/omnigraph/graphql.ts +++ b/packages/enssdk/src/omnigraph/graphql.ts @@ -10,6 +10,7 @@ import type { CoinType, DogecoinAddress, DomainId, + Email, Hex, InterfaceId, InterpretedLabel, @@ -51,6 +52,7 @@ export type OmnigraphScalars = { BigInt: `${bigint}`; JSON: JsonValue; Address: NormalizedAddress; + Email: Email; BitcoinAddress: BitcoinAddress; LitecoinAddress: LitecoinAddress; DogecoinAddress: DogecoinAddress; diff --git a/packages/integration-test-env/src/seed/resolver-records.ts b/packages/integration-test-env/src/seed/resolver-records.ts index 1fb09c05c7..0a08edbc95 100644 --- a/packages/integration-test-env/src/seed/resolver-records.ts +++ b/packages/integration-test-env/src/seed/resolver-records.ts @@ -24,19 +24,14 @@ async function seedResolverRecordsForName( ); } - // Text records - await setTextRecord(clients.owner, resolver, node, "avatar", "https://example.com/avatar.png"); - await setTextRecord(clients.owner, resolver, node, "com.twitter", "ensdomains"); - await setTextRecord(clients.owner, resolver, node, "com.github", "ensdomains"); - await setTextRecord(clients.owner, resolver, node, "url", "https://ens.domains"); - await setTextRecord(clients.owner, resolver, node, "email", "test@ens.domains"); - await setTextRecord(clients.owner, resolver, node, "description", "test.eth"); + for (const record of Object.values(fixtures.textRecords)) { + await setTextRecord(clients.owner, resolver, node, record.key, record.value); + } // Multi-coin addresses - // Coin 0 = Bitcoin - await setMulticoinAddress(clients.owner, resolver, node, 0n, fixtures.bitcoinAddress); - // Coin 2 = Litecoin - await setMulticoinAddress(clients.owner, resolver, node, 2n, fixtures.litecoinAddress); + for (const coin of Object.values(fixtures.rawAddresses)) { + await setMulticoinAddress(clients.owner, resolver, node, BigInt(coin.coinType), coin.raw); + } // Scalar resolver records await setContenthash(clients.owner, resolver, node, fixtures.contenthash); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 30aa7e18d5..d86ed09e69 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -935,6 +935,9 @@ importers: packages/datasources: dependencies: + '@ensdomains/address-encoder': + specifier: ^1.1.2 + version: 1.1.4 '@ponder/utils': specifier: ^0.2.18 version: 0.2.18(typescript@5.9.3)(viem@2.50.3(typescript@5.9.3)(zod@4.3.6)) @@ -15105,14 +15108,6 @@ snapshots: chai: 6.2.0 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.5(vite@6.4.2(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.20.6)(yaml@2.8.3))': - dependencies: - '@vitest/spy': 4.0.5 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 6.4.2(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.20.6)(yaml@2.8.3) - '@vitest/mocker@4.0.5(vite@6.4.2(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@vitest/spy': 4.0.5 @@ -20761,7 +20756,7 @@ snapshots: vitest@4.0.5(@types/debug@4.1.12)(@types/node@24.10.9)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.12))(lightningcss@1.32.0)(tsx@4.20.6)(yaml@2.8.3): dependencies: '@vitest/expect': 4.0.5 - '@vitest/mocker': 4.0.5(vite@6.4.2(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.20.6)(yaml@2.8.3)) + '@vitest/mocker': 4.0.5(vite@6.4.2(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.8.3)) '@vitest/pretty-format': 4.0.5 '@vitest/runner': 4.0.5 '@vitest/snapshot': 4.0.5 From ab30a8a0a463afed1f643a13f147a691c6b3d450 Mon Sep 17 00:00:00 2001 From: sevenzing Date: Tue, 2 Jun 2026 15:04:19 +0300 Subject: [PATCH 05/19] changeset --- .changeset/domain-profile-omnigraph.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/domain-profile-omnigraph.md diff --git a/.changeset/domain-profile-omnigraph.md b/.changeset/domain-profile-omnigraph.md new file mode 100644 index 0000000000..d0c1258f71 --- /dev/null +++ b/.changeset/domain-profile-omnigraph.md @@ -0,0 +1,7 @@ +--- +"ensapi": patch +--- + +**Omnigraph — interpreted `profile` on forward resolution** + +- Implement `Domain.resolve.profile` and `PrimaryNameRecord.resolve.profile` with field parsers driven by the GraphQL selection set (description, avatar/header `httpUrl`, website, validated `email`, multicoin `addresses`, socials) From 77a534b51133ff4604e5c866110d51f1e720595e Mon Sep 17 00:00:00 2001 From: sevenzing Date: Tue, 2 Jun 2026 15:10:33 +0300 Subject: [PATCH 06/19] small fixes after self review --- .../resolution/profile/parsers/addresses.ts | 69 ++++++------------- packages/datasources/src/devnet/constants.ts | 2 +- 2 files changed, 23 insertions(+), 48 deletions(-) diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/addresses.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/addresses.ts index f7d8d7a005..a34265ef96 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/addresses.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/addresses.ts @@ -1,4 +1,4 @@ -import { type CoinName, getCoderByCoinName, getCoderByCoinType } from "@ensdomains/address-encoder"; +import { type CoinName, getCoderByCoinName } from "@ensdomains/address-encoder"; import { hexToBytes } from "@ensdomains/address-encoder/utils"; import { type BinanceAddress, @@ -18,27 +18,30 @@ import { isHex } from "viem"; import type { ProfileFieldParser } from "./types"; const buildAddressParser = ( - coinNameOrType: CoinName | number, - format: (encoded: string) => T, + coinNameOrType: CoinName, + format?: (encoded: string) => T, ): ProfileFieldParser => { - const coder = - typeof coinNameOrType === "number" - ? getCoderByCoinType(coinNameOrType) - : getCoderByCoinName(coinNameOrType); + const coder = getCoderByCoinName(coinNameOrType); const coinType = coder.coinType as CoinType; return { selection: { addresses: [coinType] }, parse: (records) => { const raw = records.addresses?.[coinType]; - if (raw == null || raw === "" || raw === "0x") return null; + if (raw == null || raw === "0x") return null; if (!isHex(raw)) return null; try { const bytes = hexToBytes(raw); if (bytes.length === 0 || bytes.every((byte) => byte === 0)) return null; - return format(coder.encode(bytes)); + const encoded = coder.encode(bytes); + + if (format) { + return format(encoded); + } + + return encoded as T; } catch { return null; } @@ -48,45 +51,17 @@ const buildAddressParser = ( export const ProfileAddressEthereumParser = buildAddressParser("eth", toNormalizedAddress); export const ProfileAddressBaseParser = buildAddressParser("base", toNormalizedAddress); -export const ProfileAddressBitcoinParser = buildAddressParser( - "btc", - (address) => address as BitcoinAddress, -); -export const ProfileAddressSolanaParser = buildAddressParser( - "sol", - (address) => address as SolanaAddress, -); -export const ProfileAddressLitecoinParser = buildAddressParser( - "ltc", - (address) => address as LitecoinAddress, -); -export const ProfileAddressDogecoinParser = buildAddressParser( - "doge", - (address) => address as DogecoinAddress, -); -export const ProfileAddressMonacoinParser = buildAddressParser( - "mona", - (address) => address as MonacoinAddress, -); -/** Rootstock (RBTC) — coinType 137, EIP-55 checksummed EVM address. */ -export const ProfileAddressRootstockParser = buildAddressParser( - 137, - (address) => address as RootstockAddress, -); -export const ProfileAddressRippleParser = buildAddressParser( - "xrp", - (address) => address as RippleAddress, -); -export const ProfileAddressBitcoinCashParser = buildAddressParser( - "bch", - (address) => address as BitcoinCashAddress, -); -export const ProfileAddressBinanceParser = buildAddressParser( - "bnb", - (address) => address as BinanceAddress, -); +export const ProfileAddressBitcoinParser = buildAddressParser("btc"); +export const ProfileAddressSolanaParser = buildAddressParser("sol"); +export const ProfileAddressLitecoinParser = buildAddressParser("ltc"); +export const ProfileAddressDogecoinParser = buildAddressParser("doge"); +export const ProfileAddressMonacoinParser = buildAddressParser("mona"); + +export const ProfileAddressRootstockParser = buildAddressParser("rbtc"); +export const ProfileAddressRippleParser = buildAddressParser("xrp"); +export const ProfileAddressBitcoinCashParser = buildAddressParser("bch"); +export const ProfileAddressBinanceParser = buildAddressParser("bnb"); -/** All address parsers keyed by their GraphQL field name. */ export const ADDRESS_PARSERS = { ethereum: ProfileAddressEthereumParser, base: ProfileAddressBaseParser, diff --git a/packages/datasources/src/devnet/constants.ts b/packages/datasources/src/devnet/constants.ts index aa75dd355f..4995028e0a 100644 --- a/packages/datasources/src/devnet/constants.ts +++ b/packages/datasources/src/devnet/constants.ts @@ -1,4 +1,4 @@ -import { type CoinName, coinNameToTypeMap, getCoderByCoinName } from "@ensdomains/address-encoder"; +import { type CoinName, getCoderByCoinName } from "@ensdomains/address-encoder"; import { bytesToHex } from "@ensdomains/address-encoder/utils"; import type { CoinType, NormalizedAddress } from "enssdk"; import { asNormalizedAddress, toNormalizedAddress } from "enssdk"; From 7d8681cae0d4cec07b4a5bcde4d5dd4a9a8f1d07 Mon Sep 17 00:00:00 2001 From: sevenzing Date: Tue, 2 Jun 2026 15:16:16 +0300 Subject: [PATCH 07/19] rename Domain profile to name profile --- .../lib/resolution/profile/README.md | 12 +----------- .../src/omnigraph-api/schema/forward-resolve.ts | 2 +- apps/ensapi/src/omnigraph-api/schema/profile.ts | 14 +++++++------- .../src/omnigraph/generated/schema.graphql | 16 ++++++++-------- 4 files changed, 17 insertions(+), 27 deletions(-) diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/README.md b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/README.md index dbdc82631e..1bf4c5039c 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/README.md +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/README.md @@ -1,4 +1,4 @@ -# Domain profile resolution +# Name profile resolution Interpreted ENS profile fields exposed on `Domain.profile` (and `PrimaryNameRecord.profile`) in the Omnigraph API. Raw resolver records are resolved in one round-trip; each GraphQL field is backed by a `ProfileFieldParser` that declares its record selection and parsing logic. @@ -77,13 +77,3 @@ Record names use a `texts.` prefix for ENS text records and `addresses. parent.records, diff --git a/apps/ensapi/src/omnigraph-api/schema/profile.ts b/apps/ensapi/src/omnigraph-api/schema/profile.ts index 8fa313cc4e..bb2e1e6267 100644 --- a/apps/ensapi/src/omnigraph-api/schema/profile.ts +++ b/apps/ensapi/src/omnigraph-api/schema/profile.ts @@ -18,7 +18,7 @@ export const ProfileSocialAccountRef = builder.objectRef("ProfileSocialAccount"); ProfileSocialAccountRef.implement({ - description: "An interpreted social account on a Domain profile.", + description: "An interpreted social account on a Name profile.", fields: (t) => ({ handle: t.exposeString("handle", { description: "The social handle.", @@ -34,7 +34,7 @@ ProfileSocialAccountRef.implement({ export const ProfileSocialsRef = builder.objectRef("ProfileSocials"); ProfileSocialsRef.implement({ - description: "Interpreted social accounts on a Domain profile.", + description: "Interpreted social accounts on a Name profile.", fields: (t) => ({ github: t.field({ type: ProfileSocialAccountRef, @@ -67,7 +67,7 @@ ProfileSocialsRef.implement({ export const ProfileAddressesRef = builder.objectRef("ProfileAddresses"); ProfileAddressesRef.implement({ - description: "Interpreted address records on a Domain profile.", + description: "Interpreted address records on a Name profile.", fields: (t) => ({ ethereum: t.field({ description: "The interpreted Ethereum address, or null when unset.", @@ -141,7 +141,7 @@ ProfileAddressesRef.implement({ export const ProfileAvatarRef = builder.objectRef("ProfileAvatar"); ProfileAvatarRef.implement({ - description: "Interpreted avatar metadata on a Domain profile.", + description: "Interpreted avatar metadata on a Name profile.", fields: (t) => ({ httpUrl: t.exposeString("httpUrl", { description: profileImageHttpUrlDescription("avatar"), @@ -153,7 +153,7 @@ ProfileAvatarRef.implement({ export const ProfileHeaderRef = builder.objectRef("ProfileHeader"); ProfileHeaderRef.implement({ - description: "Interpreted header metadata on a Domain profile.", + description: "Interpreted header metadata on a Name profile.", fields: (t) => ({ httpUrl: t.exposeString("httpUrl", { description: profileImageHttpUrlDescription("header"), @@ -165,7 +165,7 @@ ProfileHeaderRef.implement({ export const ProfileWebsiteRef = builder.objectRef("ProfileWebsite"); ProfileWebsiteRef.implement({ - description: "Interpreted website metadata on a Domain profile.", + description: "Interpreted website metadata on a Name profile.", fields: (t) => ({ httpUrl: t.string({ description: "The HTTP-compatible website URL, or null when unset.", @@ -178,7 +178,7 @@ ProfileWebsiteRef.implement({ export const DomainProfileRef = builder.objectRef("DomainProfile"); DomainProfileRef.implement({ - description: "An interpreted ENS profile for a name.", + description: "An interpreted profile for a name.", fields: (t) => ({ avatar: t.field({ type: ProfileAvatarRef, diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index 4391d31e19..886443d541 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -413,7 +413,7 @@ input DomainPermissionsWhereInput { user: DomainPermissionsUserFilter } -"""An interpreted ENS profile for a name.""" +"""An interpreted profile for a name.""" type DomainProfile { addresses: ProfileAddresses avatar: ProfileAvatar @@ -963,7 +963,7 @@ type ForwardResolve { acceleration: AccelerationStatus! """ - An interpreted ENS profile for this Domain. Returns null when the name is not resolvable (non-canonical, unnormalized, or no profile field was selected). + An interpreted profile for this name. Returns null when the name is not resolvable (non-canonical, unnormalized, or no profile field was selected). """ profile: DomainProfile @@ -1256,7 +1256,7 @@ input PrimaryNamesWhereInput @oneOf { coinTypes: [CoinType!] } -"""Interpreted address records on a Domain profile.""" +"""Interpreted address records on a Name profile.""" type ProfileAddresses { """The interpreted Base address, or null when unset.""" base: Address @@ -1292,7 +1292,7 @@ type ProfileAddresses { solana: SolanaAddress } -"""Interpreted avatar metadata on a Domain profile.""" +"""Interpreted avatar metadata on a Name profile.""" type ProfileAvatar { """ Provides a HTTP-compatible URL for fetching the avatar image that can be safely referenced as an image in web browsers. This is an abstraction over the "raw" avatar record, which may reference non-HTTP compatible URLs or encodings including IPFS urls, CAIP-22 / CAIP-29 NFT References, and more edge cases that cannot be trivially referenced as an image in most web browsers. Additional details here: https://docs.ens.domains/ensip/12 @@ -1300,7 +1300,7 @@ type ProfileAvatar { httpUrl: String } -"""Interpreted header metadata on a Domain profile.""" +"""Interpreted header metadata on a Name profile.""" type ProfileHeader { """ Provides a HTTP-compatible URL for fetching the header image that can be safely referenced as an image in web browsers. This is an abstraction over the "raw" header record, which may reference non-HTTP compatible URLs or encodings including IPFS urls, CAIP-22 / CAIP-29 NFT References, and more edge cases that cannot be trivially referenced as an image in most web browsers. Additional details here: https://docs.ens.domains/ensip/12 @@ -1308,7 +1308,7 @@ type ProfileHeader { httpUrl: String } -"""An interpreted social account on a Domain profile.""" +"""An interpreted social account on a Name profile.""" type ProfileSocialAccount { """The social handle.""" handle: String! @@ -1317,7 +1317,7 @@ type ProfileSocialAccount { httpUrl: String! } -"""Interpreted social accounts on a Domain profile.""" +"""Interpreted social accounts on a Name profile.""" type ProfileSocials { github: ProfileSocialAccount keybase: ProfileSocialAccount @@ -1326,7 +1326,7 @@ type ProfileSocials { twitter: ProfileSocialAccount } -"""Interpreted website metadata on a Domain profile.""" +"""Interpreted website metadata on a Name profile.""" type ProfileWebsite { """The HTTP-compatible website URL, or null when unset.""" httpUrl: String From 283a464702d9ff1a18917b38eac53f2fd078a250 Mon Sep 17 00:00:00 2001 From: sevenzing Date: Tue, 2 Jun 2026 15:26:11 +0300 Subject: [PATCH 08/19] add urltype for tests --- .../omnigraph-api/schema/account.integration.test.ts | 10 ++++++++-- .../omnigraph-api/schema/domain.integration.test.ts | 5 +++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts b/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts index b63a0412ec..9a9760b555 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts @@ -1,4 +1,10 @@ -import { ETH_COIN_TYPE, evmChainIdToCoinType, type Hex, type InterpretedName } from "enssdk"; +import { + ETH_COIN_TYPE, + evmChainIdToCoinType, + type Hex, + type InterpretedName, + type UrlString, +} from "enssdk"; import { base } from "viem/chains"; import { beforeAll, describe, expect, it } from "vitest"; @@ -330,7 +336,7 @@ describe("Account.primaryName and Account.primaryNames", () => { records?: { addresses: Array<{ coinType: number; address: Hex | null }> } | null; profile?: { description: string | null; - avatar: { httpUrl: string | null } | null; + avatar: { httpUrl: UrlString | null } | null; } | null; } | null; }; diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts b/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts index d338e2891d..ac828d24cc 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts @@ -16,6 +16,7 @@ import { makeENSv2RegistryId, makeStorageId, type NormalizedAddress, + type UrlString, } from "enssdk"; import { beforeAll, describe, expect, it } from "vitest"; @@ -701,9 +702,9 @@ describe("Domain.profile", () => { resolve: { profile: { description: string | null; - avatar: { httpUrl: string | null } | null; + avatar: { httpUrl: UrlString | null } | null; addresses: { ethereum: NormalizedAddress | null } | null; - socials: { github: { handle: string; httpUrl: string } | null } | null; + socials: { github: { handle: string; httpUrl: UrlString } | null } | null; } | null; }; }; From e4a8cdce8b99924ed461e2447002ec099c29dc47 Mon Sep 17 00:00:00 2001 From: sevenzing Date: Tue, 2 Jun 2026 15:30:12 +0300 Subject: [PATCH 09/19] self review --- .../src/omnigraph-api/schema/scalars.ts | 49 +++++++++---------- 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/apps/ensapi/src/omnigraph-api/schema/scalars.ts b/apps/ensapi/src/omnigraph-api/schema/scalars.ts index 96b871cf0b..62070c0f18 100644 --- a/apps/ensapi/src/omnigraph-api/schema/scalars.ts +++ b/apps/ensapi/src/omnigraph-api/schema/scalars.ts @@ -1,4 +1,4 @@ -import { getCoderByCoinType } from "@ensdomains/address-encoder"; +import { type CoinName, coinNameToTypeMap, getCoderByCoinType } from "@ensdomains/address-encoder"; import { type BeautifiedLabel, type BeautifiedName, @@ -71,8 +71,9 @@ builder.scalarType("Email", { parseValue: (value) => makeEmailSchema("Email").parse(value), }); -const makeCoinAddressSchema = (label: string, coinType: number) => - z.coerce.string().check((ctx) => { +const makeCoinAddressSchema = (coinName: CoinName, label: string) => { + const coinType = coinNameToTypeMap[coinName]; + return z.coerce.string().check((ctx) => { try { getCoderByCoinType(coinType).decode(ctx.value); } catch { @@ -83,64 +84,62 @@ const makeCoinAddressSchema = (label: string, coinType: number) => }); } }); +}; builder.scalarType("BitcoinAddress", { - description: "BitcoinAddress represents a Base58Check-encoded Bitcoin address (coin type 0).", + description: `BitcoinAddress represents a Base58Check-encoded Bitcoin address (coin type ${coinNameToTypeMap.btc}).`, serialize: (value: BitcoinAddress) => value, - parseValue: (value) => makeCoinAddressSchema("Bitcoin", 0).parse(value) as BitcoinAddress, + parseValue: (value) => makeCoinAddressSchema("btc", "Bitcoin").parse(value) as BitcoinAddress, }); builder.scalarType("SolanaAddress", { - description: "SolanaAddress represents a Base58-encoded Solana address (coin type 501).", + description: `SolanaAddress represents a Base58-encoded Solana address (coin type ${coinNameToTypeMap.sol}).`, serialize: (value: SolanaAddress) => value, - parseValue: (value) => makeCoinAddressSchema("Solana", 501).parse(value) as SolanaAddress, + parseValue: (value) => makeCoinAddressSchema("sol", "Solana").parse(value) as SolanaAddress, }); builder.scalarType("LitecoinAddress", { - description: "LitecoinAddress represents a Base58Check-encoded Litecoin address (coin type 2).", + description: `LitecoinAddress represents a Base58Check-encoded Litecoin address (coin type ${coinNameToTypeMap.ltc}).`, serialize: (value: LitecoinAddress) => value, - parseValue: (value) => makeCoinAddressSchema("Litecoin", 2).parse(value) as LitecoinAddress, + parseValue: (value) => makeCoinAddressSchema("ltc", "Litecoin").parse(value) as LitecoinAddress, }); builder.scalarType("DogecoinAddress", { - description: "DogecoinAddress represents a Base58Check-encoded Dogecoin address (coin type 3).", + description: `DogecoinAddress represents a Base58Check-encoded Dogecoin address (coin type ${coinNameToTypeMap.doge}).`, serialize: (value: DogecoinAddress) => value, - parseValue: (value) => makeCoinAddressSchema("Dogecoin", 3).parse(value) as DogecoinAddress, + parseValue: (value) => makeCoinAddressSchema("doge", "Dogecoin").parse(value) as DogecoinAddress, }); builder.scalarType("MonacoinAddress", { - description: "MonacoinAddress represents a Base58Check-encoded Monacoin address (coin type 22).", + description: `MonacoinAddress represents a Base58Check-encoded Monacoin address (coin type ${coinNameToTypeMap.mona}).`, serialize: (value: MonacoinAddress) => value, - parseValue: (value) => makeCoinAddressSchema("Monacoin", 22).parse(value) as MonacoinAddress, + parseValue: (value) => makeCoinAddressSchema("mona", "Monacoin").parse(value) as MonacoinAddress, }); builder.scalarType("RootstockAddress", { - description: - "RootstockAddress represents an EIP-55 checksummed Rootstock (RBTC) address (coin type 137).", + description: `RootstockAddress represents an EIP-55 checksummed Rootstock (RBTC) address (coin type ${coinNameToTypeMap.rbtc}).`, serialize: (value: RootstockAddress) => value, - parseValue: (value) => makeCoinAddressSchema("Rootstock", 137).parse(value) as RootstockAddress, + parseValue: (value) => + makeCoinAddressSchema("rbtc", "Rootstock").parse(value) as RootstockAddress, }); builder.scalarType("RippleAddress", { - description: - "RippleAddress represents a Base58Check-encoded Ripple (XRP) address (coin type 144).", + description: `RippleAddress represents a Base58Check-encoded Ripple (XRP) address (coin type ${coinNameToTypeMap.xrp}).`, serialize: (value: RippleAddress) => value, - parseValue: (value) => makeCoinAddressSchema("Ripple", 144).parse(value) as RippleAddress, + parseValue: (value) => makeCoinAddressSchema("xrp", "Ripple").parse(value) as RippleAddress, }); builder.scalarType("BitcoinCashAddress", { - description: - "BitcoinCashAddress represents a CashAddr-encoded Bitcoin Cash address (coin type 145).", + description: `BitcoinCashAddress represents a CashAddr-encoded Bitcoin Cash address (coin type ${coinNameToTypeMap.bch}).`, serialize: (value: BitcoinCashAddress) => value, parseValue: (value) => - makeCoinAddressSchema("Bitcoin Cash", 145).parse(value) as BitcoinCashAddress, + makeCoinAddressSchema("bch", "Bitcoin Cash").parse(value) as BitcoinCashAddress, }); builder.scalarType("BinanceAddress", { - description: - "BinanceAddress represents a Bech32-encoded Binance Chain (BNB) address (coin type 714).", + description: `BinanceAddress represents a Bech32-encoded Binance Chain (BNB) address (coin type ${coinNameToTypeMap.bnb}).`, serialize: (value: BinanceAddress) => value, - parseValue: (value) => makeCoinAddressSchema("Binance", 714).parse(value) as BinanceAddress, + parseValue: (value) => makeCoinAddressSchema("bnb", "Binance").parse(value) as BinanceAddress, }); builder.scalarType("Hex", { From 2ed418f1e138aafca8cdc49142c038a9fe005588 Mon Sep 17 00:00:00 2001 From: sevenzing Date: Tue, 2 Jun 2026 15:48:35 +0300 Subject: [PATCH 10/19] AI review --- .../profile/build-profile-selection.test.ts | 19 ++++++++++++++++++- .../lib/resolution/profile/parsers/images.ts | 4 ++-- .../lib/resolution/profile/parsers/social.ts | 6 +++--- .../lib/resolution/profile/parsers/texts.ts | 9 ++++----- .../ensapi/src/omnigraph-api/schema/domain.ts | 11 ++++------- .../schema/primary-name-record.ts | 11 ++++------- .../ensnode-sdk/src/shared/zod-schemas.ts | 6 +++--- .../src/lib/ens-metadata-service.test.ts | 11 ++++++++++- .../enssdk/src/lib/ens-metadata-service.ts | 4 ++++ 9 files changed, 52 insertions(+), 29 deletions(-) diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/build-profile-selection.test.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/build-profile-selection.test.ts index b1401c1296..5b992d1c62 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/build-profile-selection.test.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/build-profile-selection.test.ts @@ -116,7 +116,24 @@ describe("buildProfileSelectionFromResolveContainerInfo", () => { ).toBeNull(); }); - it("builds selection from inline fragments within profile selection", () => { + it("builds selection from inline fragment within profile selection", () => { + expect( + buildProfileSelectionFromResolveContainerInfo( + resolveInfoForSubselection(` + profile { + ... on DomainProfile { + description + avatar { httpUrl } + } + } + `), + ), + ).toEqual({ + texts: ["description", "avatar"], + }); + }); + + it("builds selection from named fragment spread within profile selection", () => { const doc = parse(` fragment ProfileFields on DomainProfile { description diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/images.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/images.ts index 59d8baffe5..5fb7acda52 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/images.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/images.ts @@ -27,8 +27,8 @@ const buildImageParser = ( ): ProfileFieldParser => ({ selection: { texts: [record] }, parse: (records) => { - const raw = records.texts?.[record]; - if (raw == null || raw === "") return null; + const raw = records.texts?.[record]?.trim(); + if (!raw) return null; const httpUrl = parseDirectImageHttpUrl(raw) ?? interpretProfileImageHttpUrl(records, raw, record); diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/social.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/social.ts index 06836d3606..fcf99f1cad 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/social.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/social.ts @@ -27,9 +27,8 @@ function pathOffsetFromBaseUrl(baseUrl: string): number { * - Bare handle: `itslevchiks` * - With leading @: `@itslevchiks` * - Full URL: `https://github.com/itslevchiks`, `http://github.com/itslevchiks` - * - Repo URL (when `allowDeepPath`): `https://github.com/itslevchiks/my-repo` + * - Repo URL: `https://github.com/itslevchiks/my-repo` * - URL without scheme: `github.com/itslevchiks` - * - Trailing slash, query strings, hash fragments are ignored * * Returns null when the value is missing, empty, unparseable, or the extracted * handle does not pass the character-class validation. @@ -53,7 +52,8 @@ export function parseSocialHandle({ const url = new URL(toParse); if (hostnames.includes(url.hostname)) { const segments = url.pathname.split("/").filter((s) => s.length > 0); - handle = segments.slice(pathOffset).join("/") ?? null; + handle = segments.slice(pathOffset).join("/"); + handle = handle === "" ? null : handle; if (handle) { const baseUrlParsed = new URL(baseUrl); diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/texts.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/texts.ts index 52c376a320..48cbdba367 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/texts.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/texts.ts @@ -20,8 +20,8 @@ export const ProfileDescriptionParser: ProfileFieldParser = textParser(" export const ProfileEmailParser: ProfileFieldParser = { selection: { texts: ["email"] }, parse: (records) => { - const raw = records.texts?.email; - if (raw == null || raw === "") return null; + const raw = records.texts?.email?.trim(); + if (!raw) return null; const parsed = profileEmailSchema.safeParse(raw); return parsed.success ? parsed.data : null; }, @@ -30,9 +30,8 @@ export const ProfileEmailParser: ProfileFieldParser = { const urlParser = (key: string): ProfileFieldParser => ({ selection: { texts: [key] }, parse: (records) => { - const raw = records.texts?.[key]; - if (raw == null || raw === "") return null; - const trimmed = raw.trim(); + const trimmed = records.texts?.[key]?.trim(); + if (!trimmed) return null; if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) { return null; } diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.ts b/apps/ensapi/src/omnigraph-api/schema/domain.ts index b2e8160c18..a08aef0b84 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.ts @@ -204,13 +204,10 @@ DomainInterfaceRef.implement({ return { accelerate, canAccelerate, trace: null, records: null }; } - const mergedSelection = - name && isNormalizedName(name) - ? mergeRecordsSelections( - buildRecordsSelectionFromResolveContainerInfo(info), - buildProfileSelectionFromResolveContainerInfo(info), - ) - : null; + const mergedSelection = mergeRecordsSelections( + buildRecordsSelectionFromResolveContainerInfo(info), + buildProfileSelectionFromResolveContainerInfo(info), + ); if (!mergedSelection) { return { accelerate, canAccelerate, trace: null, records: null }; diff --git a/apps/ensapi/src/omnigraph-api/schema/primary-name-record.ts b/apps/ensapi/src/omnigraph-api/schema/primary-name-record.ts index f5e713f703..70bc6a7e05 100644 --- a/apps/ensapi/src/omnigraph-api/schema/primary-name-record.ts +++ b/apps/ensapi/src/omnigraph-api/schema/primary-name-record.ts @@ -66,13 +66,10 @@ PrimaryNameRecordRef.implement({ return { accelerate, canAccelerate, trace: null, records: null }; } - const mergedSelection = - name && isNormalizedName(name) - ? mergeRecordsSelections( - buildRecordsSelectionFromResolveContainerInfo(info), - buildProfileSelectionFromResolveContainerInfo(info), - ) - : null; + const mergedSelection = mergeRecordsSelections( + buildRecordsSelectionFromResolveContainerInfo(info), + buildProfileSelectionFromResolveContainerInfo(info), + ); if (!mergedSelection) { return { accelerate, canAccelerate, trace: null, records: null }; diff --git a/packages/ensnode-sdk/src/shared/zod-schemas.ts b/packages/ensnode-sdk/src/shared/zod-schemas.ts index 735035cacd..124a304af6 100644 --- a/packages/ensnode-sdk/src/shared/zod-schemas.ts +++ b/packages/ensnode-sdk/src/shared/zod-schemas.ts @@ -151,9 +151,6 @@ export const makeCoinTypeStringSchema = (valueLabel: string = "Coin Type String" .pipe(z.coerce.number({ error: `${valueLabel} must represent a non-negative integer (>=0).` })) .pipe(makeCoinTypeSchema(`The numeric value represented by ${valueLabel}`)); -/** - * Parses a serialized representation of an EVM address into a {@link NormalizedAddress}. - */ /** * Parses a string into a validated {@link Email} (trimmed). */ @@ -164,6 +161,9 @@ export const makeEmailSchema = (valueLabel: string = "Email") => .pipe(z.email({ error: `${valueLabel} must be a valid email address.` })) .transform((value) => value as Email); +/** + * Parses a serialized representation of an EVM address into a {@link NormalizedAddress}. + */ export const makeNormalizedAddressSchema = (valueLabel: string = "EVM address") => z .string() diff --git a/packages/enssdk/src/lib/ens-metadata-service.test.ts b/packages/enssdk/src/lib/ens-metadata-service.test.ts index 9e703c9cf0..7ca3a0a860 100644 --- a/packages/enssdk/src/lib/ens-metadata-service.test.ts +++ b/packages/enssdk/src/lib/ens-metadata-service.test.ts @@ -1,4 +1,4 @@ -import { asInterpretedName } from "enssdk"; +import { asInterpretedName, type Name } from "enssdk"; import { describe, expect, it } from "vitest"; import { @@ -19,6 +19,15 @@ describe("getEnsMetadataServiceImageUrl", () => { expect(getEnsMetadataServiceImageUrl(name, "ens-test-env", "avatar")).toBeNull(); expect(getEnsMetadataServiceImageUrl(name, "sepolia-v2", "avatar")).toBeNull(); }); + + it.each([ + ["absolute https URL", "https://evil.example/avatar.png"], + ["absolute http URL", "http://evil.example/avatar.png"], + ["protocol-relative URL", "//evil.example/avatar.png"], + ["custom scheme", "javascript:alert(1)"], + ])("returns null for non-name input: %s", (_message, maliciousName) => { + expect(getEnsMetadataServiceImageUrl(maliciousName as Name, "mainnet", "avatar")).toBeNull(); + }); }); describe("getEnsMetadataServiceAvatarUrl", () => { diff --git a/packages/enssdk/src/lib/ens-metadata-service.ts b/packages/enssdk/src/lib/ens-metadata-service.ts index dbeeffdc46..c85366fd50 100644 --- a/packages/enssdk/src/lib/ens-metadata-service.ts +++ b/packages/enssdk/src/lib/ens-metadata-service.ts @@ -10,6 +10,8 @@ const METADATA_NETWORKS = { type MetadataNetwork = (typeof METADATA_NETWORKS)[keyof typeof METADATA_NETWORKS]; +const URI_SCHEME_PATTERN = /^[a-zA-Z][a-zA-Z0-9+.-]*:/; + function namespaceIdToMetadataNetwork(namespaceId: string): MetadataNetwork | null { switch (namespaceId) { case "mainnet": @@ -34,6 +36,8 @@ export function getEnsMetadataServiceImageUrl( namespaceId: string, record: EnsMetadataImageRecord, ): URL | null { + if (name.startsWith("//") || URI_SCHEME_PATTERN.test(name)) return null; + const network = namespaceIdToMetadataNetwork(namespaceId); if (!network) return null; From 9d577956df54a0adf8692f99c4b1a0d6c57cfb3c Mon Sep 17 00:00:00 2001 From: sevenzing Date: Tue, 2 Jun 2026 15:55:04 +0300 Subject: [PATCH 11/19] fix typecheck --- .../lib/resolution/profile/parsers/addresses.test.ts | 5 +++-- .../lib/resolution/profile/parsers/test-helpers.ts | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/addresses.test.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/addresses.test.ts index 966d77ee9e..667cba05a3 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/addresses.test.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/addresses.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { ADDRESS_PARSERS } from "./addresses"; import { profileRecordsModel } from "./test-helpers"; +import { Hex } from "viem"; describe("ADDRESS_PARSERS", () => { it.each([ @@ -83,12 +84,12 @@ describe("ADDRESS_PARSERS", () => { ["record unset", undefined], ["empty string", ""], ["0x sentinel", "0x"], - ["non-hex value", "not-hex"], + ["non-hex value", "0xnot-hex"], ] as const)("returns null: %s (%s)", (_message, raw) => { for (const [field, parser] of Object.entries(ADDRESS_PARSERS)) { const coinType = parser.selection.addresses?.[0]; if (coinType == null) throw new Error(`Coin type not found for parser ${field}`); - const model = raw === undefined ? {} : { [coinType]: raw }; + const model = raw === undefined ? {} : { [coinType]: raw as Hex }; expect(parser.parse(profileRecordsModel({}, model))).toBeNull(); } }); diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/test-helpers.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/test-helpers.ts index 6d3a817f3d..ad28f41e04 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/test-helpers.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/test-helpers.ts @@ -1,8 +1,9 @@ import type { ResolvedRecordsModel } from "@/omnigraph-api/lib/resolution/records-profile-model"; +import { Hex } from "viem"; export const profileRecordsModel = ( texts?: Record, - addresses?: Record, + addresses?: Record, ): ResolvedRecordsModel => ({ id: "test.eth" as ResolvedRecordsModel["id"], texts: texts ?? {}, From a883fe4809a3dcc137618961427566bfc5c5b0b4 Mon Sep 17 00:00:00 2001 From: sevenzing Date: Tue, 2 Jun 2026 15:56:18 +0300 Subject: [PATCH 12/19] lint --- .../lib/resolution/profile/parsers/addresses.test.ts | 2 +- .../lib/resolution/profile/parsers/test-helpers.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/addresses.test.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/addresses.test.ts index 667cba05a3..e3c134f1c0 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/addresses.test.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/addresses.test.ts @@ -1,8 +1,8 @@ +import type { Hex } from "viem"; import { describe, expect, it } from "vitest"; import { ADDRESS_PARSERS } from "./addresses"; import { profileRecordsModel } from "./test-helpers"; -import { Hex } from "viem"; describe("ADDRESS_PARSERS", () => { it.each([ diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/test-helpers.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/test-helpers.ts index ad28f41e04..0165219473 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/test-helpers.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/test-helpers.ts @@ -1,5 +1,6 @@ +import type { Hex } from "viem"; + import type { ResolvedRecordsModel } from "@/omnigraph-api/lib/resolution/records-profile-model"; -import { Hex } from "viem"; export const profileRecordsModel = ( texts?: Record, From 8915213cf1f7fa48554223543b4d8a11364245c3 Mon Sep 17 00:00:00 2001 From: sevenzing Date: Tue, 2 Jun 2026 19:30:38 +0300 Subject: [PATCH 13/19] apply fixes for comments from @shrugs --- .changeset/domain-profile-omnigraph.md | 4 +-- .../lib/resolution/profile/README.md | 4 +-- .../resolution/profile/parsers/addresses.ts | 9 +++--- .../lib/resolution/profile/parsers/images.ts | 8 ++--- .../lib/resolution/profile/parsers/social.ts | 6 ++-- .../profile/parsers/test-helpers.ts | 9 ++++-- .../lib/resolution/profile/parsers/texts.ts | 12 +++---- .../lib/resolution/profile/parsers/types.ts | 2 +- .../lib/resolution/records-profile-model.ts | 14 ++++++--- .../lib/resolution/records-selection.test.ts | 13 ++++++++ .../lib/resolution/records-selection.ts | 15 +++++---- .../ensapi/src/omnigraph-api/schema/domain.ts | 12 +++---- .../omnigraph-api/schema/forward-resolve.ts | 10 +++--- .../schema/primary-name-record.ts | 12 +++---- .../src/omnigraph-api/schema/records.ts | 31 ++++++++++--------- packages/enssdk/src/lib/types/email.ts | 4 +++ 16 files changed, 96 insertions(+), 69 deletions(-) diff --git a/.changeset/domain-profile-omnigraph.md b/.changeset/domain-profile-omnigraph.md index d0c1258f71..a11332c89f 100644 --- a/.changeset/domain-profile-omnigraph.md +++ b/.changeset/domain-profile-omnigraph.md @@ -2,6 +2,4 @@ "ensapi": patch --- -**Omnigraph — interpreted `profile` on forward resolution** - -- Implement `Domain.resolve.profile` and `PrimaryNameRecord.resolve.profile` with field parsers driven by the GraphQL selection set (description, avatar/header `httpUrl`, website, validated `email`, multicoin `addresses`, socials) +**Omnigraph API:** Introduces `Domain.resolve.profile` and `PrimaryNameRecord.resolve.profile` for resolving semantic record values. diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/README.md b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/README.md index 1bf4c5039c..d5f0496a05 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/README.md +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/README.md @@ -1,6 +1,6 @@ # Name profile resolution -Interpreted ENS profile fields exposed on `Domain.profile` (and `PrimaryNameRecord.profile`) in the Omnigraph API. Raw resolver records are resolved in one round-trip; each GraphQL field is backed by a `ProfileFieldParser` that declares its record selection and parsing logic. +Interpreted ENS profile fields exposed on `Domain.resolve.profile` (and `PrimaryNameRecord.resolve.profile`) in the Omnigraph API. Raw resolver records are resolved in one round-trip; each GraphQL field is backed by a `ProfileFieldParser` that declares its record selection and parsing logic. ## Architecture @@ -21,7 +21,7 @@ GraphQL wiring lives in `apps/ensapi/src/omnigraph-api/schema/profile.ts`. Each parser is a singleton with: - `selection` — which text keys / coin types must be fetched -- `parse(records)` — derive the GraphQL output from `ResolvedRecordsModel` +- `parse(result)` — derive the GraphQL output from `ResolvedRecordsModel` `buildProfileSelectionFromResolveContainerInfo` merges parser selections based on the client's `profile { ... }` sub-selection. diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/addresses.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/addresses.ts index a34265ef96..c94b1cd673 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/addresses.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/addresses.ts @@ -1,5 +1,4 @@ import { type CoinName, getCoderByCoinName } from "@ensdomains/address-encoder"; -import { hexToBytes } from "@ensdomains/address-encoder/utils"; import { type BinanceAddress, type BitcoinAddress, @@ -13,7 +12,7 @@ import { type SolanaAddress, toNormalizedAddress, } from "enssdk"; -import { isHex } from "viem"; +import { isHex, toBytes } from "viem"; import type { ProfileFieldParser } from "./types"; @@ -26,13 +25,13 @@ const buildAddressParser = ( return { selection: { addresses: [coinType] }, - parse: (records) => { - const raw = records.addresses?.[coinType]; + parse: (result) => { + const raw = result.records.addresses?.[coinType]; if (raw == null || raw === "0x") return null; if (!isHex(raw)) return null; try { - const bytes = hexToBytes(raw); + const bytes = toBytes(raw); if (bytes.length === 0 || bytes.every((byte) => byte === 0)) return null; const encoded = coder.encode(bytes); diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/images.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/images.ts index 5fb7acda52..e187d24fc0 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/images.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/images.ts @@ -26,12 +26,12 @@ const buildImageParser = ( record: EnsMetadataImageRecord, ): ProfileFieldParser => ({ selection: { texts: [record] }, - parse: (records) => { - const raw = records.texts?.[record]?.trim(); + parse: (result) => { + const raw = result.records.texts?.[record]?.trim(); if (!raw) return null; const httpUrl = - parseDirectImageHttpUrl(raw) ?? interpretProfileImageHttpUrl(records, raw, record); + parseDirectImageHttpUrl(raw) ?? interpretProfileImageHttpUrl(result, raw, record); return { httpUrl }; }, @@ -55,7 +55,7 @@ function interpretProfileImageHttpUrl( ): string | null { if (!rawValue) return null; - return getEnsMetadataServiceImageUrl(model.id, di.context.namespace, record)?.href ?? null; + return getEnsMetadataServiceImageUrl(model.name, di.context.namespace, record)?.href ?? null; } export const profileImageHttpUrlDescription = (recordLabel: "avatar" | "header") => diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/social.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/social.ts index fcf99f1cad..5a3097f1cf 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/social.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/social.ts @@ -87,10 +87,10 @@ const socialParser = ( const opts = { hostnames, baseUrl, handlePattern }; return { selection: { texts: keys }, - parse: (records) => { + parse: (result) => { for (const key of keys) { - const result = parseSocialHandle({ value: records.texts?.[key], ...opts }); - if (result !== null) return result; + const parsed = parseSocialHandle({ value: result.records.texts?.[key], ...opts }); + if (parsed !== null) return parsed; } return null; }, diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/test-helpers.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/test-helpers.ts index 0165219473..1dad2494f9 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/test-helpers.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/test-helpers.ts @@ -1,3 +1,4 @@ +import { asInterpretedName } from "enssdk"; import type { Hex } from "viem"; import type { ResolvedRecordsModel } from "@/omnigraph-api/lib/resolution/records-profile-model"; @@ -6,7 +7,9 @@ export const profileRecordsModel = ( texts?: Record, addresses?: Record, ): ResolvedRecordsModel => ({ - id: "test.eth" as ResolvedRecordsModel["id"], - texts: texts ?? {}, - addresses: addresses ?? {}, + name: asInterpretedName("test.eth"), + records: { + texts: texts ?? {}, + addresses: addresses ?? {}, + }, }); diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/texts.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/texts.ts index 48cbdba367..2061d3003f 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/texts.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/texts.ts @@ -8,8 +8,8 @@ const profileEmailSchema = makeEmailSchema("email text record"); const textParser = (key: string): ProfileFieldParser => ({ selection: { texts: [key] }, - parse: (records) => { - const raw = records.texts?.[key]; + parse: (result) => { + const raw = result.records.texts?.[key]; if (raw == null || raw === "") return null; return raw; }, @@ -19,8 +19,8 @@ export const ProfileDescriptionParser: ProfileFieldParser = textParser(" export const ProfileEmailParser: ProfileFieldParser = { selection: { texts: ["email"] }, - parse: (records) => { - const raw = records.texts?.email?.trim(); + parse: (result) => { + const raw = result.records.texts?.email?.trim(); if (!raw) return null; const parsed = profileEmailSchema.safeParse(raw); return parsed.success ? parsed.data : null; @@ -29,8 +29,8 @@ export const ProfileEmailParser: ProfileFieldParser = { const urlParser = (key: string): ProfileFieldParser => ({ selection: { texts: [key] }, - parse: (records) => { - const trimmed = records.texts?.[key]?.trim(); + parse: (result) => { + const trimmed = result.records.texts?.[key]?.trim(); if (!trimmed) return null; if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) { return null; diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/types.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/types.ts index 6bb9a52414..85d0886cf7 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/types.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/types.ts @@ -12,5 +12,5 @@ export interface ProfileFieldParser { /** The record keys this parser requires. Merged into the parent selection before resolution. */ selection: ResolverRecordsSelection; /** Derive the GraphQL output from the resolved records, or null if the record is unset. */ - parse(records: ResolvedRecordsModel): TOutput | null; + parse(result: ResolvedRecordsModel): TOutput | null; } diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/records-profile-model.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/records-profile-model.ts index b0c491ad05..9481f8aa31 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/records-profile-model.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/records-profile-model.ts @@ -2,15 +2,19 @@ import type { InterpretedName } from "enssdk"; import type { ResolverRecordsResponseBase } from "@ensnode/ensnode-sdk"; -/** Cache key and resolution identity for {@link ResolvedRecordsRef}. */ -export type ResolvedRecordsModel = Partial & { - id: InterpretedName; +/** Resolved records for a name, including resolution identity and record payload. */ +export type ResolvedRecordsModel = { + name: InterpretedName; + records: Partial; }; +/** Forward-resolution outcome carrying {@link ResolvedRecordsModel}. */ +export type ResolvedRecordsResultModel = ResolvedRecordsModel; + export const toResolvedRecordsModel = ( name: InterpretedName, response: Partial, ): ResolvedRecordsModel => ({ - id: name, - ...response, + name, + records: response, }); diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.test.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.test.ts index bcb3c93745..df56dfa2cd 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.test.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.test.ts @@ -267,4 +267,17 @@ describe("mergeRecordsSelections", () => { addresses: [60, 61], }); }); + + it("deduplicates texts, addresses, and interfaces", () => { + expect( + mergeRecordsSelections( + { texts: ["avatar", "description"], addresses: [60], interfaces: ["0x01ffc9a7"] }, + { texts: ["description", "email"], addresses: [60, 61], interfaces: ["0x01ffc9a7"] }, + ), + ).toEqual({ + texts: ["avatar", "description", "email"], + addresses: [60, 61], + interfaces: ["0x01ffc9a7"], + }); + }); }); diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.ts index 55c5b53aa3..1378a793bb 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/records-selection.ts @@ -10,7 +10,7 @@ import { type SelectionSetNode, } from "graphql"; -import { isSelectionEmpty, type ResolverRecordsSelection } from "@ensnode/ensnode-sdk"; +import { isSelectionEmpty, type ResolverRecordsSelection, uniq } from "@ensnode/ensnode-sdk"; import { getParametricRecordsSelectionField, @@ -131,8 +131,7 @@ function buildRecordsSelectionFromRecordsFieldNodes( /** * Merges two nullable {@link ResolverRecordsSelection} objects into one. * - * - `texts` and `addresses` arrays are unioned (duplicates preserved; callers should deduplicate - * if needed, but the resolution layer handles duplicates gracefully). + * - `texts`, `addresses`, and `interfaces` arrays are unioned with duplicates removed. * - `abi` content-type bitmasks are OR-ed so that all requested content types are fetched. * - Boolean flags are OR-ed. * - Returns null only when both inputs are null. @@ -147,14 +146,18 @@ export function mergeRecordsSelections( return { name: a.name || b.name || undefined, - texts: a.texts || b.texts ? [...(a.texts ?? []), ...(b.texts ?? [])] : undefined, + texts: a.texts || b.texts ? uniq([...(a.texts ?? []), ...(b.texts ?? [])]) : undefined, addresses: - a.addresses || b.addresses ? [...(a.addresses ?? []), ...(b.addresses ?? [])] : undefined, + a.addresses || b.addresses + ? uniq([...(a.addresses ?? []), ...(b.addresses ?? [])]) + : undefined, contenthash: a.contenthash || b.contenthash || undefined, pubkey: a.pubkey || b.pubkey || undefined, abi: a.abi !== undefined || b.abi !== undefined ? (a.abi ?? 0n) | (b.abi ?? 0n) : undefined, interfaces: - a.interfaces || b.interfaces ? [...(a.interfaces ?? []), ...(b.interfaces ?? [])] : undefined, + a.interfaces || b.interfaces + ? uniq([...(a.interfaces ?? []), ...(b.interfaces ?? [])]) + : undefined, dnszonehash: a.dnszonehash || b.dnszonehash || undefined, version: a.version || b.version || undefined, }; diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.ts b/apps/ensapi/src/omnigraph-api/schema/domain.ts index a08aef0b84..25cd91262c 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.ts @@ -201,27 +201,27 @@ DomainInterfaceRef.implement({ const name = domain.canonicalName; if (!name || !isNormalizedName(name)) { - return { accelerate, canAccelerate, trace: null, records: null }; + return { accelerate, canAccelerate, trace: null, result: null }; } - const mergedSelection = mergeRecordsSelections( + const selection = mergeRecordsSelections( buildRecordsSelectionFromResolveContainerInfo(info), buildProfileSelectionFromResolveContainerInfo(info), ); - if (!mergedSelection) { - return { accelerate, canAccelerate, trace: null, records: null }; + if (!selection) { + return { accelerate, canAccelerate, trace: null, result: null }; } const { trace, result } = await runWithTrace(() => - resolveForward(name, mergedSelection, { accelerate, canAccelerate }), + resolveForward(name, selection, { accelerate, canAccelerate }), ); return { accelerate, canAccelerate, trace, - records: toResolvedRecordsModel(name, result), + result: toResolvedRecordsModel(name, result), }; }, }), diff --git a/apps/ensapi/src/omnigraph-api/schema/forward-resolve.ts b/apps/ensapi/src/omnigraph-api/schema/forward-resolve.ts index a5777460c2..609a13c41f 100644 --- a/apps/ensapi/src/omnigraph-api/schema/forward-resolve.ts +++ b/apps/ensapi/src/omnigraph-api/schema/forward-resolve.ts @@ -3,7 +3,7 @@ import type { JsonValue } from "enssdk"; import type { TracingTrace } from "@ensnode/ensnode-sdk"; import { builder } from "@/omnigraph-api/builder"; -import type { ResolvedRecordsModel } from "@/omnigraph-api/lib/resolution/records-profile-model"; +import type { ResolvedRecordsResultModel } from "@/omnigraph-api/lib/resolution/records-profile-model"; import { DomainProfileRef } from "@/omnigraph-api/schema/profile"; import { ResolvedRecordsRef } from "@/omnigraph-api/schema/records"; import { AccelerationStatusRef } from "@/omnigraph-api/schema/resolution"; @@ -12,7 +12,7 @@ export type ForwardResolveModel = { accelerate: boolean; canAccelerate: boolean; trace: TracingTrace | null; - records: ResolvedRecordsModel | null; + result: ResolvedRecordsResultModel | null; }; export const ForwardResolveRef = builder.objectRef("ForwardResolve"); @@ -42,14 +42,14 @@ ForwardResolveRef.implement({ type: ResolvedRecordsRef, nullable: true, tracing: true, - resolve: (parent) => parent.records, + resolve: (parent) => parent.result, }), profile: t.field({ description: - "An interpreted profile for this name. Returns null when the name is not resolvable (non-canonical, unnormalized, or no profile field was selected).", + "An interpreted profile for this name. Returns null when the name is not resolvable (non-canonical, unnormalized, or no profile records were selected).", type: DomainProfileRef, nullable: true, - resolve: (parent) => parent.records, + resolve: (parent) => parent.result, }), }), }); diff --git a/apps/ensapi/src/omnigraph-api/schema/primary-name-record.ts b/apps/ensapi/src/omnigraph-api/schema/primary-name-record.ts index 70bc6a7e05..1e42ef3556 100644 --- a/apps/ensapi/src/omnigraph-api/schema/primary-name-record.ts +++ b/apps/ensapi/src/omnigraph-api/schema/primary-name-record.ts @@ -63,27 +63,27 @@ PrimaryNameRecordRef.implement({ const { canAccelerate } = context; if (!name || !isNormalizedName(name)) { - return { accelerate, canAccelerate, trace: null, records: null }; + return { accelerate, canAccelerate, trace: null, result: null }; } - const mergedSelection = mergeRecordsSelections( + const selection = mergeRecordsSelections( buildRecordsSelectionFromResolveContainerInfo(info), buildProfileSelectionFromResolveContainerInfo(info), ); - if (!mergedSelection) { - return { accelerate, canAccelerate, trace: null, records: null }; + if (!selection) { + return { accelerate, canAccelerate, trace: null, result: null }; } const { trace, result } = await runWithTrace(() => - resolveForward(name, mergedSelection, { accelerate, canAccelerate }), + resolveForward(name, selection, { accelerate, canAccelerate }), ); return { accelerate, canAccelerate, trace, - records: toResolvedRecordsModel(name, result), + result: toResolvedRecordsModel(name, result), }; }, }), diff --git a/apps/ensapi/src/omnigraph-api/schema/records.ts b/apps/ensapi/src/omnigraph-api/schema/records.ts index 2e81d925f7..a3ac88c030 100644 --- a/apps/ensapi/src/omnigraph-api/schema/records.ts +++ b/apps/ensapi/src/omnigraph-api/schema/records.ts @@ -130,7 +130,10 @@ ResolvedInterfaceRecordRef.implement({ //////////////////// // ResolvedRecords //////////////////// -export type { ResolvedRecordsModel }; +export type { + ResolvedRecordsModel, + ResolvedRecordsResultModel, +} from "@/omnigraph-api/lib/resolution/records-profile-model"; export const ResolvedRecordsRef = builder.objectRef("ResolvedRecords"); @@ -141,31 +144,31 @@ ResolvedRecordsRef.implement({ description: "The `name` record value used in Reverse Resolution (ENSIP-19), or null if not set. To reduce a common point of developer confusion the Omnigraph API represents this as the `reverseName` rather than the `name` record which is what this field actually resolves to onchain.", nullable: true, - resolve: (r) => r.name ?? null, + resolve: (r) => r.records.name ?? null, }), contenthash: t.field({ description: "The ENSIP-7 contenthash record raw bytes, or null if not set.", type: "Hex", nullable: true, - resolve: (r) => r.contenthash ?? null, + resolve: (r) => r.records.contenthash ?? null, }), pubkey: t.field({ description: "The PubkeyResolver (x, y) pair, or null if not set.", type: ResolvedPubkeyRecordRef, nullable: true, - resolve: (r) => r.pubkey ?? null, + resolve: (r) => r.records.pubkey ?? null, }), dnszonehash: t.field({ description: "The IDNSZoneResolver zonehash raw bytes, or null if not set.", type: "Hex", nullable: true, - resolve: (r) => r.dnszonehash ?? null, + resolve: (r) => r.records.dnszonehash ?? null, }), version: t.field({ description: "The IVersionableResolver version, or null if not set or unavailable.", type: "BigInt", nullable: true, - resolve: (r) => r.version ?? null, + resolve: (r) => r.records.version ?? null, }), abi: t.field({ description: @@ -191,11 +194,11 @@ ResolvedRecordsRef.implement({ @see https://docs.ens.domains/ensip/4/ */ - if (!r.abi) return null; + if (!r.records.abi) return null; // check if the found contentType matches the requested contentTypeMask - const foundContentType = r.abi.contentType & contentTypeMask; + const foundContentType = r.records.abi.contentType & contentTypeMask; if (foundContentType === 0n) return null; - return r.abi; + return r.records.abi; }, }), interfaces: t.field({ @@ -211,10 +214,10 @@ ResolvedRecordsRef.implement({ }, resolve: (r, { ids }) => // preserve the order of requested interface ids - r.interfaces + r.records.interfaces ? ids.map((interfaceId) => ({ interfaceId, - implementer: r.interfaces?.[interfaceId] ?? null, + implementer: r.records.interfaces?.[interfaceId] ?? null, })) : [], }), @@ -230,7 +233,7 @@ ResolvedRecordsRef.implement({ }, resolve: (r, { keys }) => // preserve the order of requested text keys - r.texts ? keys.map((key) => ({ key, value: r.texts?.[key] ?? null })) : [], + r.records.texts ? keys.map((key) => ({ key, value: r.records.texts?.[key] ?? null })) : [], }), addresses: t.field({ description: "Resolved address records for the requested coin types.", @@ -244,11 +247,11 @@ ResolvedRecordsRef.implement({ }), }, resolve: (r, { coinTypes }) => - r.addresses + r.records.addresses ? // preserve the order of requested coin types coinTypes.map((coinType) => ({ coinType, - address: r.addresses?.[coinType] ?? null, + address: r.records.addresses?.[coinType] ?? null, })) : [], }), diff --git a/packages/enssdk/src/lib/types/email.ts b/packages/enssdk/src/lib/types/email.ts index f6b80846d9..9e8196370f 100644 --- a/packages/enssdk/src/lib/types/email.ts +++ b/packages/enssdk/src/lib/types/email.ts @@ -1,4 +1,8 @@ /** * A contact email address normalized by trimming and validated as a well-formed email. + * + * Values are validated with the same guarantees as Zod's `z.email()` (after trim). + * + * @see https://zod.dev/api?id=emails#emails */ export type Email = string; From be19fd1f771d87b4db9a05a3c4fb4720c23ff474 Mon Sep 17 00:00:00 2001 From: sevenzing Date: Tue, 2 Jun 2026 19:48:07 +0300 Subject: [PATCH 14/19] rearrange tests with claude --- .../resolution/profile/parsers/social.test.ts | 439 +++++++----------- 1 file changed, 175 insertions(+), 264 deletions(-) diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/social.test.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/social.test.ts index fc8b32774b..f5d7e61e40 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/social.test.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/social.test.ts @@ -8,48 +8,94 @@ import { SocialTwitterParser, } from "./social"; import { profileRecordsModel } from "./test-helpers"; +import type { ProfileFieldParser } from "./types"; -describe("SocialGithubParser", () => { - it("has correct selection", () => { - expect(SocialGithubParser.selection).toEqual({ texts: ["com.github", "vnd.github"] }); +type SocialResult = { handle: string; httpUrl: string }; + +/** + * Generates common test cases shared across social parsers: + * bare handle, @-prefixed, leading/trailing whitespace. + */ +function commonParseCases( + handle: string, + expected: SocialResult, +): [label: string, input: string, expected: SocialResult][] { + return [ + ["bare handle", handle, expected], + ["@ prefix", `@${handle}`, expected], + ["surrounding whitespace", ` ${handle} `, expected], + ]; +} + +/** + * Generates common null-result test cases shared across social parsers. + */ +function commonNullCases(primaryKey: string): [label: string, texts: Record][] { + return [ + ["record unset", {}], + ["empty string", { [primaryKey]: "" }], + ["whitespace only", { [primaryKey]: " " }], + ]; +} + +/** + * Generates URL-variant test cases for a given hostname and base URL. + */ +function urlVariantCases( + handle: string, + hostname: string, + expected: SocialResult, +): [label: string, input: string, expected: SocialResult][] { + return [ + ["https URL", `https://${hostname}/${handle}`, expected], + ["http URL", `http://${hostname}/${handle}`, expected], + ["hostname without scheme", `${hostname}/${handle}`, expected], + ["trailing slash", `https://${hostname}/${handle}/`, expected], + ]; +} + +function describeSocialParser( + name: string, + parser: ProfileFieldParser, + primaryKey: string, + { + selection, + parseCases, + nullCases = [], + }: { + selection: { texts: string[] }; + parseCases: [label: string, input: string, expected: SocialResult][]; + nullCases?: [label: string, texts: Record][]; + }, +) { + describe(name, () => { + it("has correct selection", () => { + expect(parser.selection).toEqual(selection); + }); + + it.each(parseCases)("parses %s", (_label, input, expected) => { + expect(parser.parse(profileRecordsModel({ [primaryKey]: input }))).toEqual(expected); + }); + + it.each([...commonNullCases(primaryKey), ...nullCases])("returns null: %s", (_label, texts) => { + expect(parser.parse(profileRecordsModel(texts))).toBeNull(); + }); }); +} - it.each([ - [ - "bare handle", - "itslevchiks", - { handle: "itslevchiks", httpUrl: "https://github.com/itslevchiks" }, - ], - [ - "@ prefix", - "@itslevchiks", - { handle: "itslevchiks", httpUrl: "https://github.com/itslevchiks" }, - ], - [ - "https URL", - "https://github.com/itslevchiks", - { handle: "itslevchiks", httpUrl: "https://github.com/itslevchiks" }, - ], - [ - "http URL", - "http://github.com/itslevchiks", - { handle: "itslevchiks", httpUrl: "https://github.com/itslevchiks" }, - ], - [ - "hostname without scheme", - "github.com/itslevchiks", - { handle: "itslevchiks", httpUrl: "https://github.com/itslevchiks" }, - ], - [ - "www hostname", - "www.github.com/itslevchiks", - { handle: "itslevchiks", httpUrl: "https://github.com/itslevchiks" }, - ], - [ - "trailing slash", - "https://github.com/itslevchiks/", - { handle: "itslevchiks", httpUrl: "https://github.com/itslevchiks" }, - ], +// --- GitHub --- + +const EXPECTED_GITHUB: SocialResult = { + handle: "itslevchiks", + httpUrl: "https://github.com/itslevchiks", +}; + +describeSocialParser("SocialGithubParser", SocialGithubParser, "com.github", { + selection: { texts: ["com.github", "vnd.github"] }, + parseCases: [ + ...commonParseCases("itslevchiks", EXPECTED_GITHUB), + ...urlVariantCases("itslevchiks", "github.com", EXPECTED_GITHUB), + ["www hostname", "www.github.com/itslevchiks", EXPECTED_GITHUB], [ "query string", "https://github.com/itslevchiks?tab=repos", @@ -60,11 +106,6 @@ describe("SocialGithubParser", () => { "https://github.com/itslevchiks#readme", { handle: "itslevchiks", httpUrl: "https://github.com/itslevchiks#readme" }, ], - [ - "surrounding whitespace", - " itslevchiks ", - { handle: "itslevchiks", httpUrl: "https://github.com/itslevchiks" }, - ], [ "hyphen and underscore", "My-Handle_99", @@ -88,136 +129,116 @@ describe("SocialGithubParser", () => { "itslevchiks/some-repo", { handle: "itslevchiks/some-repo", httpUrl: "https://github.com/itslevchiks/some-repo" }, ], - ])("parses %s", (_message, input, expected) => { - expect(SocialGithubParser.parse(profileRecordsModel({ "com.github": input }))).toEqual( - expected, - ); - }); - - it.each([ - ["record unset", {}], - ["empty string", { "com.github": "" }], - ["whitespace only", { "com.github": " " }], + ], + nullCases: [ ["invalid handle characters", { "com.github": "invalid user name!" }], ["foreign social URL", { "com.github": "https://twitter.com/itslevchiks" }], - ])("returns null: %s", (_message, texts) => { - expect(SocialGithubParser.parse(profileRecordsModel(texts))).toBeNull(); - }); + ], }); -describe("SocialTwitterParser", () => { - it("has correct selection", () => { - expect(SocialTwitterParser.selection).toEqual({ - texts: ["com.x", "com.twitter", "vnd.twitter"], - }); - }); +// --- Twitter --- - it.each([ - ["bare handle", "itslevchiks", { handle: "itslevchiks", httpUrl: "https://x.com/itslevchiks" }], - ["@ prefix", "@itslevchiks", { handle: "itslevchiks", httpUrl: "https://x.com/itslevchiks" }], - [ - "https x.com URL", - "https://x.com/itslevchiks", - { handle: "itslevchiks", httpUrl: "https://x.com/itslevchiks" }, - ], - [ - "http x.com URL", - "http://x.com/itslevchiks", - { handle: "itslevchiks", httpUrl: "https://x.com/itslevchiks" }, - ], - [ - "x.com without scheme", - "x.com/itslevchiks", - { handle: "itslevchiks", httpUrl: "https://x.com/itslevchiks" }, - ], - [ - "twitter.com hostname", - "www.twitter.com/itslevchiks", - { handle: "itslevchiks", httpUrl: "https://x.com/itslevchiks" }, - ], - [ - "trailing slash", - "twitter.com/itslevchiks/", - { handle: "itslevchiks", httpUrl: "https://x.com/itslevchiks" }, - ], +const EXPECTED_TWITTER: SocialResult = { + handle: "itslevchiks", + httpUrl: "https://x.com/itslevchiks", +}; + +describeSocialParser("SocialTwitterParser", SocialTwitterParser, "com.x", { + selection: { texts: ["com.x", "com.twitter", "vnd.twitter"] }, + parseCases: [ + ...commonParseCases("itslevchiks", EXPECTED_TWITTER), + ["https x.com URL", "https://x.com/itslevchiks", EXPECTED_TWITTER], + ["http x.com URL", "http://x.com/itslevchiks", EXPECTED_TWITTER], + ["x.com without scheme", "x.com/itslevchiks", EXPECTED_TWITTER], + ["twitter.com hostname", "www.twitter.com/itslevchiks", EXPECTED_TWITTER], + ["trailing slash", "twitter.com/itslevchiks/", EXPECTED_TWITTER], [ "query string", "twitter.com/itslevchiks?lang=en", { handle: "itslevchiks", httpUrl: "https://x.com/itslevchiks?lang=en" }, ], - ])("parses %s", (_message, input, expected) => { - expect(SocialTwitterParser.parse(profileRecordsModel({ "com.x": input }))).toEqual(expected); - }); - - it.each([ - ["record unset", {}], - ["empty string", { "com.x": "" }], + ], + nullCases: [ ["invalid handle characters", { "com.x": "hello world" }], ["foreign social URL", { "com.x": "https://github.com/itslevchiks" }], - ])("returns null: %s", (_message, texts) => { - expect(SocialTwitterParser.parse(profileRecordsModel(texts))).toBeNull(); - }); + ], }); -describe("SocialTelegramParser", () => { - it("has correct selection", () => { - expect(SocialTelegramParser.selection).toEqual({ texts: ["org.telegram"] }); - }); +// --- Telegram --- - it.each([ - ["bare handle", "itslevchiks", { handle: "itslevchiks", httpUrl: "https://t.me/itslevchiks" }], - ["@ prefix", "@itslevchiks", { handle: "itslevchiks", httpUrl: "https://t.me/itslevchiks" }], - [ - "https t.me URL", - "https://t.me/itslevchiks", - { handle: "itslevchiks", httpUrl: "https://t.me/itslevchiks" }, - ], - [ - "http t.me URL", - "http://t.me/itslevchiks", - { handle: "itslevchiks", httpUrl: "https://t.me/itslevchiks" }, - ], - [ - "t.me without scheme", - "t.me/itslevchiks", - { handle: "itslevchiks", httpUrl: "https://t.me/itslevchiks" }, - ], - [ - "telegram.me hostname", - "telegram.me/itslevchiks", - { handle: "itslevchiks", httpUrl: "https://t.me/itslevchiks" }, - ], - [ - "trailing slash", - "t.me/itslevchiks/", - { handle: "itslevchiks", httpUrl: "https://t.me/itslevchiks" }, - ], - ])("parses %s", (_message, input, expected) => { - expect(SocialTelegramParser.parse(profileRecordsModel({ "org.telegram": input }))).toEqual( - expected, - ); - }); +const EXPECTED_TELEGRAM: SocialResult = { + handle: "itslevchiks", + httpUrl: "https://t.me/itslevchiks", +}; - it.each([ - ["record unset", {}], - ["empty string", { "org.telegram": "" }], +describeSocialParser("SocialTelegramParser", SocialTelegramParser, "org.telegram", { + selection: { texts: ["org.telegram"] }, + parseCases: [ + ...commonParseCases("itslevchiks", EXPECTED_TELEGRAM), + ["https t.me URL", "https://t.me/itslevchiks", EXPECTED_TELEGRAM], + ["http t.me URL", "http://t.me/itslevchiks", EXPECTED_TELEGRAM], + ["t.me without scheme", "t.me/itslevchiks", EXPECTED_TELEGRAM], + ["telegram.me hostname", "telegram.me/itslevchiks", EXPECTED_TELEGRAM], + ["trailing slash", "t.me/itslevchiks/", EXPECTED_TELEGRAM], + ], + nullCases: [ ["invalid handle characters", { "org.telegram": "bad handle!" }], ["foreign social URL", { "org.telegram": "https://twitter.com/itslevchiks" }], - ])("returns null: %s", (_message, texts) => { - expect(SocialTelegramParser.parse(profileRecordsModel(texts))).toBeNull(); - }); + ], }); -describe("SocialGithubParser (vnd.github fallback)", () => { - it("has correct selection (includes vnd.github)", () => { - expect(SocialGithubParser.selection).toEqual({ texts: ["com.github", "vnd.github"] }); - }); +// --- LinkedIn --- + +const EXPECTED_LINKEDIN: SocialResult = { + handle: "itslevchiks", + httpUrl: "https://www.linkedin.com/in/itslevchiks", +}; +describeSocialParser("SocialLinkedInParser", SocialLinkedInParser, "com.linkedin", { + selection: { texts: ["com.linkedin"] }, + parseCases: [ + ...commonParseCases("itslevchiks", EXPECTED_LINKEDIN), + ["https URL", "https://linkedin.com/in/itslevchiks", EXPECTED_LINKEDIN], + ["www hostname", "https://www.linkedin.com/in/itslevchiks", EXPECTED_LINKEDIN], + [ + "handle with hyphen", + "my-handle", + { handle: "my-handle", httpUrl: "https://www.linkedin.com/in/my-handle" }, + ], + ["trailing slash", "https://linkedin.com/in/itslevchiks/", EXPECTED_LINKEDIN], + ], + nullCases: [ + ["invalid handle characters", { "com.linkedin": "bad handle!" }], + ["foreign social URL", { "com.linkedin": "https://twitter.com/itslevchiks" }], + ], +}); + +// --- Keybase --- + +const EXPECTED_KEYBASE: SocialResult = { + handle: "itslevchiks", + httpUrl: "https://keybase.io/itslevchiks", +}; + +describeSocialParser("SocialKeybaseParser", SocialKeybaseParser, "io.keybase", { + selection: { texts: ["io.keybase"] }, + parseCases: [ + ...commonParseCases("itslevchiks", EXPECTED_KEYBASE), + ...urlVariantCases("itslevchiks", "keybase.io", EXPECTED_KEYBASE), + ], + nullCases: [ + ["invalid handle characters", { "io.keybase": "bad handle!" }], + ["foreign social URL", { "io.keybase": "https://github.com/itslevchiks" }], + ], +}); + +// --- Fallback key tests --- + +describe("SocialGithubParser (vnd.github fallback)", () => { it("falls back to vnd.github when com.github is unset", () => { - expect(SocialGithubParser.parse(profileRecordsModel({ "vnd.github": "itslevchiks" }))).toEqual({ - handle: "itslevchiks", - httpUrl: "https://github.com/itslevchiks", - }); + expect(SocialGithubParser.parse(profileRecordsModel({ "vnd.github": "itslevchiks" }))).toEqual( + EXPECTED_GITHUB, + ); }); it("prefers com.github over vnd.github", () => { @@ -238,16 +259,10 @@ describe("SocialGithubParser (vnd.github fallback)", () => { }); describe("SocialTwitterParser (text key fallbacks)", () => { - it("has correct selection (includes com.twitter and vnd.twitter)", () => { - expect(SocialTwitterParser.selection).toEqual({ - texts: ["com.x", "com.twitter", "vnd.twitter"], - }); - }); - it("falls back to com.twitter when com.x is unset", () => { expect( SocialTwitterParser.parse(profileRecordsModel({ "com.twitter": "itslevchiks" })), - ).toEqual({ handle: "itslevchiks", httpUrl: "https://x.com/itslevchiks" }); + ).toEqual(EXPECTED_TWITTER); }); it("prefers com.x over com.twitter", () => { @@ -267,7 +282,7 @@ describe("SocialTwitterParser (text key fallbacks)", () => { it("falls back to vnd.twitter when com.x and com.twitter are unset", () => { expect( SocialTwitterParser.parse(profileRecordsModel({ "vnd.twitter": "itslevchiks" })), - ).toEqual({ handle: "itslevchiks", httpUrl: "https://x.com/itslevchiks" }); + ).toEqual(EXPECTED_TWITTER); }); it("prefers com.twitter over vnd.twitter", () => { @@ -286,107 +301,3 @@ describe("SocialTwitterParser (text key fallbacks)", () => { ).toEqual({ handle: "legacyuser", httpUrl: "https://x.com/legacyuser" }); }); }); - -describe("SocialLinkedInParser", () => { - it("has correct selection", () => { - expect(SocialLinkedInParser.selection).toEqual({ texts: ["com.linkedin"] }); - }); - - it.each([ - [ - "bare handle", - "itslevchiks", - { handle: "itslevchiks", httpUrl: "https://www.linkedin.com/in/itslevchiks" }, - ], - [ - "@ prefix", - "@itslevchiks", - { handle: "itslevchiks", httpUrl: "https://www.linkedin.com/in/itslevchiks" }, - ], - [ - "https URL", - "https://linkedin.com/in/itslevchiks", - { handle: "itslevchiks", httpUrl: "https://www.linkedin.com/in/itslevchiks" }, - ], - [ - "www hostname", - "https://www.linkedin.com/in/itslevchiks", - { handle: "itslevchiks", httpUrl: "https://www.linkedin.com/in/itslevchiks" }, - ], - [ - "handle with hyphen", - "my-handle", - { handle: "my-handle", httpUrl: "https://www.linkedin.com/in/my-handle" }, - ], - [ - "trailing slash", - "https://linkedin.com/in/itslevchiks/", - { handle: "itslevchiks", httpUrl: "https://www.linkedin.com/in/itslevchiks" }, - ], - ])("parses %s", (_message, input, expected) => { - expect(SocialLinkedInParser.parse(profileRecordsModel({ "com.linkedin": input }))).toEqual( - expected, - ); - }); - - it.each([ - ["record unset", {}], - ["empty string", { "com.linkedin": "" }], - ["invalid handle characters", { "com.linkedin": "bad handle!" }], - ["foreign social URL", { "com.linkedin": "https://twitter.com/itslevchiks" }], - ])("returns null: %s", (_message, texts) => { - expect(SocialLinkedInParser.parse(profileRecordsModel(texts))).toBeNull(); - }); -}); - -describe("SocialKeybaseParser", () => { - it("has correct selection", () => { - expect(SocialKeybaseParser.selection).toEqual({ texts: ["io.keybase"] }); - }); - - it.each([ - [ - "bare handle", - "itslevchiks", - { handle: "itslevchiks", httpUrl: "https://keybase.io/itslevchiks" }, - ], - [ - "@ prefix", - "@itslevchiks", - { handle: "itslevchiks", httpUrl: "https://keybase.io/itslevchiks" }, - ], - [ - "https URL", - "https://keybase.io/itslevchiks", - { handle: "itslevchiks", httpUrl: "https://keybase.io/itslevchiks" }, - ], - [ - "http URL", - "http://keybase.io/itslevchiks", - { handle: "itslevchiks", httpUrl: "https://keybase.io/itslevchiks" }, - ], - [ - "without scheme", - "keybase.io/itslevchiks", - { handle: "itslevchiks", httpUrl: "https://keybase.io/itslevchiks" }, - ], - [ - "trailing slash", - "keybase.io/itslevchiks/", - { handle: "itslevchiks", httpUrl: "https://keybase.io/itslevchiks" }, - ], - ])("parses %s", (_message, input, expected) => { - expect(SocialKeybaseParser.parse(profileRecordsModel({ "io.keybase": input }))).toEqual( - expected, - ); - }); - - it.each([ - ["record unset", {}], - ["empty string", { "io.keybase": "" }], - ["invalid handle characters", { "io.keybase": "bad handle!" }], - ["foreign social URL", { "io.keybase": "https://github.com/itslevchiks" }], - ])("returns null: %s", (_message, texts) => { - expect(SocialKeybaseParser.parse(profileRecordsModel(texts))).toBeNull(); - }); -}); From 8bb1298afde3c43dd8f2d5dcbaab1137bcb16e7e Mon Sep 17 00:00:00 2001 From: sevenzing Date: Wed, 3 Jun 2026 14:01:33 +0300 Subject: [PATCH 15/19] move devnet constants to different package and move descriptions to sep file --- apps/ensapi/package.json | 1 + .../resolve-primary-name.integration.test.ts | 2 +- .../resolve-primary-names.integration.test.ts | 2 +- .../resolve-records.integration.test.ts | 7 +- .../lib/resolution/profile/parsers/images.ts | 5 - .../lib/resolution/profile/parsers/index.ts | 1 - .../profile/profile-descriptions.ts | 25 ++++ .../schema/account.integration.test.ts | 2 +- .../schema/domain.integration.test.ts | 7 +- .../src/omnigraph-api/schema/profile.ts | 72 ++++++---- .../schema/resolution.integration.test.ts | 2 +- packages/datasources/package.json | 3 +- packages/datasources/src/devnet/constants.ts | 89 +------------ packages/ensnode-sdk/package.json | 1 + .../src/omnigraph-api/example-queries.ts | 2 +- .../src/omnigraph/generated/schema.graphql | 124 ++++++++++++++---- packages/integration-test-env/package.json | 7 +- .../src/devnet/fixtures.ts | 88 +++++++++++++ .../integration-test-env/src/devnet/index.ts | 1 + .../integration-test-env/src/seed/index.ts | 2 +- .../src/seed/resolver-records.ts | 3 +- pnpm-lock.yaml | 25 +++- 22 files changed, 313 insertions(+), 158 deletions(-) create mode 100644 apps/ensapi/src/omnigraph-api/lib/resolution/profile/profile-descriptions.ts create mode 100644 packages/integration-test-env/src/devnet/fixtures.ts create mode 100644 packages/integration-test-env/src/devnet/index.ts diff --git a/apps/ensapi/package.json b/apps/ensapi/package.json index 52a90f48f9..4a9b71ed75 100644 --- a/apps/ensapi/package.json +++ b/apps/ensapi/package.json @@ -66,6 +66,7 @@ "zod": "catalog:" }, "devDependencies": { + "@ensnode/integration-test-env": "workspace:*", "@ensnode/shared-configs": "workspace:*", "@types/node": "catalog:", "@types/prismjs": "^1.26.6", diff --git a/apps/ensapi/src/handlers/api/resolution/resolve-primary-name.integration.test.ts b/apps/ensapi/src/handlers/api/resolution/resolve-primary-name.integration.test.ts index bd28d9ffa5..e222f4a285 100644 --- a/apps/ensapi/src/handlers/api/resolution/resolve-primary-name.integration.test.ts +++ b/apps/ensapi/src/handlers/api/resolution/resolve-primary-name.integration.test.ts @@ -6,7 +6,7 @@ import { describe, expect, it } from "vitest"; -import { accounts } from "@ensnode/datasources/devnet"; +import { accounts } from "@ensnode/integration-test-env/devnet"; const BASE_URL = process.env.ENSNODE_URL!; diff --git a/apps/ensapi/src/handlers/api/resolution/resolve-primary-names.integration.test.ts b/apps/ensapi/src/handlers/api/resolution/resolve-primary-names.integration.test.ts index 07f7e109e8..c171dd5857 100644 --- a/apps/ensapi/src/handlers/api/resolution/resolve-primary-names.integration.test.ts +++ b/apps/ensapi/src/handlers/api/resolution/resolve-primary-names.integration.test.ts @@ -6,7 +6,7 @@ import { describe, expect, it } from "vitest"; -import { accounts } from "@ensnode/datasources/devnet"; +import { accounts } from "@ensnode/integration-test-env/devnet"; const BASE_URL = process.env.ENSNODE_URL!; diff --git a/apps/ensapi/src/handlers/api/resolution/resolve-records.integration.test.ts b/apps/ensapi/src/handlers/api/resolution/resolve-records.integration.test.ts index e7e6adf8b3..dad9a46824 100644 --- a/apps/ensapi/src/handlers/api/resolution/resolve-records.integration.test.ts +++ b/apps/ensapi/src/handlers/api/resolution/resolve-records.integration.test.ts @@ -6,7 +6,12 @@ import { describe, expect, it } from "vitest"; -import { accounts, addresses, fixtures, testEthTextRecords } from "@ensnode/datasources/devnet"; +import { + accounts, + addresses, + fixtures, + testEthTextRecords, +} from "@ensnode/integration-test-env/devnet"; const BASE_URL = process.env.ENSNODE_URL!; diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/images.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/images.ts index e187d24fc0..7f1b7b80d3 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/images.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/images.ts @@ -57,8 +57,3 @@ function interpretProfileImageHttpUrl( return getEnsMetadataServiceImageUrl(model.name, di.context.namespace, record)?.href ?? null; } - -export const profileImageHttpUrlDescription = (recordLabel: "avatar" | "header") => - `Provides a HTTP-compatible URL for fetching the ${recordLabel} image that can be safely referenced as an image in web browsers. ` + - `This is an abstraction over the "raw" ${recordLabel} record, which may reference non-HTTP compatible URLs or encodings including IPFS urls, CAIP-22 / CAIP-29 NFT References, and more edge cases that cannot be trivially referenced as an image in most web browsers. ` + - "Additional details here: https://docs.ens.domains/ensip/12"; diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/index.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/index.ts index 1e8476a7bd..c3243f96ee 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/index.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/index.ts @@ -16,7 +16,6 @@ export type { ProfileImageResult } from "./images"; export { ProfileAvatarParser, ProfileHeaderParser, - profileImageHttpUrlDescription, } from "./images"; export { SOCIAL_PARSERS, diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/profile-descriptions.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/profile-descriptions.ts new file mode 100644 index 0000000000..6f928e82dc --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/profile-descriptions.ts @@ -0,0 +1,25 @@ +const nullWhenUninterpretable = (condition: string) => `Returns null when ${condition}.`; + +export const profileAddressFieldDescription = (coinLabel: string) => + `The interpreted ${coinLabel} address. ${nullWhenUninterpretable( + "the raw address record is unset, empty (`0x`), all-zero, not valid hex, or cannot be decoded and encoded for this coin type per ENSIP-9", + )}`; + +export const profileSocialFieldDescription = (platform: string) => + `The interpreted ${platform} account. ${nullWhenUninterpretable( + `the raw record is unset, empty, or cannot be parsed as a ${platform} handle or profile URL`, + )}`; + +export const profileWebsiteFieldDescription = + "Interpreted website metadata. Returns null when the raw url record is unset, empty, or cannot be parsed as a valid http(s) URL."; + +export const profileImageHttpUrlFieldDescription = (recordLabel: "avatar" | "header") => + `HTTP-compatible URL for fetching the ${recordLabel} image in web browsers. Abstraction over the raw ${recordLabel} record (IPFS, CAIP NFT references, etc.). ${nullWhenUninterpretable( + "the raw value is not a direct http(s) URL and no fallback URL can be derived (including when the ENS Metadata Service is unavailable for this namespace)", + )} See https://docs.ens.domains/ensip/12.`; + +export const profileAddressesContainerDescription = + "Interpreted multicoin address records on a Name profile. Each field returns null independently when its raw record cannot be interpreted."; + +export const profileSocialsContainerDescription = + "Interpreted social accounts on a Name profile. Each field returns null independently when its raw record cannot be interpreted."; diff --git a/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts b/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts index 9a9760b555..4362d64067 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts @@ -8,7 +8,7 @@ import { import { base } from "viem/chains"; import { beforeAll, describe, expect, it } from "vitest"; -import { accounts, testEthTextRecords } from "@ensnode/datasources/devnet"; +import { accounts, testEthTextRecords } from "@ensnode/integration-test-env/devnet"; import { AccountDomainsPaginated, diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts b/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts index ac828d24cc..4757131f56 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts @@ -21,8 +21,13 @@ import { import { beforeAll, describe, expect, it } from "vitest"; import { DatasourceNames } from "@ensnode/datasources"; -import { accounts, addresses, fixtures, testEthTextRecords } from "@ensnode/datasources/devnet"; import { getDatasourceContract } from "@ensnode/ensnode-sdk"; +import { + accounts, + addresses, + fixtures, + testEthTextRecords, +} from "@ensnode/integration-test-env/devnet"; import { DEVNET_ETH_LABELS, DEVNET_NAMES } from "@/test/integration/devnet-names"; import { diff --git a/apps/ensapi/src/omnigraph-api/schema/profile.ts b/apps/ensapi/src/omnigraph-api/schema/profile.ts index bb2e1e6267..bd747778b5 100644 --- a/apps/ensapi/src/omnigraph-api/schema/profile.ts +++ b/apps/ensapi/src/omnigraph-api/schema/profile.ts @@ -6,9 +6,16 @@ import { ProfileEmailParser, ProfileHeaderParser, ProfileWebsiteParser, - profileImageHttpUrlDescription, SOCIAL_PARSERS, } from "@/omnigraph-api/lib/resolution/profile/parsers"; +import { + profileAddressesContainerDescription, + profileAddressFieldDescription, + profileImageHttpUrlFieldDescription, + profileSocialFieldDescription, + profileSocialsContainerDescription, + profileWebsiteFieldDescription, +} from "@/omnigraph-api/lib/resolution/profile/profile-descriptions"; import type { ResolvedRecordsModel } from "@/omnigraph-api/lib/resolution/records-profile-model"; export type ProfileSocialAccountModel = { handle: string; httpUrl: string }; @@ -18,14 +25,15 @@ export const ProfileSocialAccountRef = builder.objectRef("ProfileSocialAccount"); ProfileSocialAccountRef.implement({ - description: "An interpreted social account on a Name profile.", + description: + "An interpreted social account. Only returned when the raw record was successfully parsed; otherwise the parent social field is null.", fields: (t) => ({ handle: t.exposeString("handle", { - description: "The social handle.", + description: "The normalized social handle extracted from the raw record.", nullable: false, }), httpUrl: t.exposeString("httpUrl", { - description: "The HTTP-compatible social profile URL.", + description: "The canonical HTTP-compatible social profile URL.", nullable: false, }), }), @@ -34,29 +42,34 @@ ProfileSocialAccountRef.implement({ export const ProfileSocialsRef = builder.objectRef("ProfileSocials"); ProfileSocialsRef.implement({ - description: "Interpreted social accounts on a Name profile.", + description: profileSocialsContainerDescription, fields: (t) => ({ github: t.field({ + description: profileSocialFieldDescription("GitHub"), type: ProfileSocialAccountRef, nullable: true, resolve: (model) => SOCIAL_PARSERS.github.parse(model), }), telegram: t.field({ + description: profileSocialFieldDescription("Telegram"), type: ProfileSocialAccountRef, nullable: true, resolve: (model) => SOCIAL_PARSERS.telegram.parse(model), }), twitter: t.field({ + description: profileSocialFieldDescription("X (Twitter)"), type: ProfileSocialAccountRef, nullable: true, resolve: (model) => SOCIAL_PARSERS.twitter.parse(model), }), linkedin: t.field({ + description: profileSocialFieldDescription("LinkedIn"), type: ProfileSocialAccountRef, nullable: true, resolve: (model) => SOCIAL_PARSERS.linkedin.parse(model), }), keybase: t.field({ + description: profileSocialFieldDescription("Keybase"), type: ProfileSocialAccountRef, nullable: true, resolve: (model) => SOCIAL_PARSERS.keybase.parse(model), @@ -67,70 +80,70 @@ ProfileSocialsRef.implement({ export const ProfileAddressesRef = builder.objectRef("ProfileAddresses"); ProfileAddressesRef.implement({ - description: "Interpreted address records on a Name profile.", + description: profileAddressesContainerDescription, fields: (t) => ({ ethereum: t.field({ - description: "The interpreted Ethereum address, or null when unset.", + description: profileAddressFieldDescription("Ethereum"), type: "Address", nullable: true, resolve: (model) => ADDRESS_PARSERS.ethereum.parse(model), }), base: t.field({ - description: "The interpreted Base address, or null when unset.", + description: profileAddressFieldDescription("Base"), type: "Address", nullable: true, resolve: (model) => ADDRESS_PARSERS.base.parse(model), }), bitcoin: t.field({ - description: "The interpreted Bitcoin address, or null when unset.", + description: profileAddressFieldDescription("Bitcoin"), type: "BitcoinAddress", nullable: true, resolve: (model) => ADDRESS_PARSERS.bitcoin.parse(model), }), solana: t.field({ - description: "The interpreted Solana address, or null when unset.", + description: profileAddressFieldDescription("Solana"), type: "SolanaAddress", nullable: true, resolve: (model) => ADDRESS_PARSERS.solana.parse(model), }), litecoin: t.field({ - description: "The interpreted Litecoin address, or null when unset.", + description: profileAddressFieldDescription("Litecoin"), type: "LitecoinAddress", nullable: true, resolve: (model) => ADDRESS_PARSERS.litecoin.parse(model), }), dogecoin: t.field({ - description: "The interpreted Dogecoin address, or null when unset.", + description: profileAddressFieldDescription("Dogecoin"), type: "DogecoinAddress", nullable: true, resolve: (model) => ADDRESS_PARSERS.dogecoin.parse(model), }), monacoin: t.field({ - description: "The interpreted Monacoin address, or null when unset.", + description: profileAddressFieldDescription("Monacoin"), type: "MonacoinAddress", nullable: true, resolve: (model) => ADDRESS_PARSERS.monacoin.parse(model), }), rootstock: t.field({ - description: "The interpreted Rootstock (RBTC) address, or null when unset.", + description: profileAddressFieldDescription("Rootstock (RBTC)"), type: "RootstockAddress", nullable: true, resolve: (model) => ADDRESS_PARSERS.rootstock.parse(model), }), ripple: t.field({ - description: "The interpreted Ripple (XRP) address, or null when unset.", + description: profileAddressFieldDescription("Ripple (XRP)"), type: "RippleAddress", nullable: true, resolve: (model) => ADDRESS_PARSERS.ripple.parse(model), }), bitcoincash: t.field({ - description: "The interpreted Bitcoin Cash address, or null when unset.", + description: profileAddressFieldDescription("Bitcoin Cash"), type: "BitcoinCashAddress", nullable: true, resolve: (model) => ADDRESS_PARSERS.bitcoincash.parse(model), }), binance: t.field({ - description: "The interpreted Binance Chain (BNB) address, or null when unset.", + description: profileAddressFieldDescription("Binance Chain (BNB)"), type: "BinanceAddress", nullable: true, resolve: (model) => ADDRESS_PARSERS.binance.parse(model), @@ -144,7 +157,7 @@ ProfileAvatarRef.implement({ description: "Interpreted avatar metadata on a Name profile.", fields: (t) => ({ httpUrl: t.exposeString("httpUrl", { - description: profileImageHttpUrlDescription("avatar"), + description: profileImageHttpUrlFieldDescription("avatar"), nullable: true, }), }), @@ -156,7 +169,7 @@ ProfileHeaderRef.implement({ description: "Interpreted header metadata on a Name profile.", fields: (t) => ({ httpUrl: t.exposeString("httpUrl", { - description: profileImageHttpUrlDescription("header"), + description: profileImageHttpUrlFieldDescription("header"), nullable: true, }), }), @@ -165,10 +178,11 @@ ProfileHeaderRef.implement({ export const ProfileWebsiteRef = builder.objectRef("ProfileWebsite"); ProfileWebsiteRef.implement({ - description: "Interpreted website metadata on a Name profile.", + description: profileWebsiteFieldDescription, fields: (t) => ({ httpUrl: t.string({ - description: "The HTTP-compatible website URL, or null when unset.", + description: + "The HTTP-compatible website URL. Returns null when the raw url record is unset, empty, not an http(s) URL, or cannot be parsed as a valid URL.", nullable: true, resolve: (model) => ProfileWebsiteParser.parse(model), }), @@ -178,40 +192,50 @@ ProfileWebsiteRef.implement({ export const DomainProfileRef = builder.objectRef("DomainProfile"); DomainProfileRef.implement({ - description: "An interpreted profile for a name.", + description: + "An interpreted profile for a name. Individual fields return null when their raw record is unset or cannot be interpreted; see each field's description for validation rules.", fields: (t) => ({ avatar: t.field({ + description: + "Interpreted avatar metadata. Returns null when the raw avatar record is unset or empty.", type: ProfileAvatarRef, nullable: true, resolve: (model) => ProfileAvatarParser.parse(model), }), header: t.field({ + description: + "Interpreted header metadata. Returns null when the raw header record is unset or empty.", type: ProfileHeaderRef, nullable: true, resolve: (model) => ProfileHeaderParser.parse(model), }), website: t.field({ + description: profileWebsiteFieldDescription, type: ProfileWebsiteRef, nullable: true, resolve: (model) => (ProfileWebsiteParser.parse(model) ? model : null), }), description: t.string({ - description: "The profile description, or null when unset.", + description: + "The profile description. Returns null when the raw record is unset or empty. Non-empty values are returned as-is without format validation.", nullable: true, resolve: (model) => ProfileDescriptionParser.parse(model), }), email: t.field({ - description: "The contact email address, or null when unset or invalid.", + description: + "The contact email address. Returns null when the raw record is unset, empty, or fails email validation.", type: "Email", nullable: true, resolve: (model) => ProfileEmailParser.parse(model), }), addresses: t.field({ + description: profileAddressesContainerDescription, type: ProfileAddressesRef, nullable: true, resolve: (model) => model, }), socials: t.field({ + description: profileSocialsContainerDescription, type: ProfileSocialsRef, nullable: true, resolve: (model) => model, diff --git a/apps/ensapi/src/omnigraph-api/schema/resolution.integration.test.ts b/apps/ensapi/src/omnigraph-api/schema/resolution.integration.test.ts index 1fb3ddf75b..ecf383b4c3 100644 --- a/apps/ensapi/src/omnigraph-api/schema/resolution.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/resolution.integration.test.ts @@ -1,7 +1,7 @@ import { ETH_COIN_TYPE } from "enssdk"; import { describe, expect, it } from "vitest"; -import { accounts } from "@ensnode/datasources/devnet"; +import { accounts } from "@ensnode/integration-test-env/devnet"; import { request } from "@/test/integration/graphql-utils"; import { gql } from "@/test/integration/omnigraph-api-client"; diff --git a/packages/datasources/package.json b/packages/datasources/package.json index 1afb5d193d..fe59daabc5 100644 --- a/packages/datasources/package.json +++ b/packages/datasources/package.json @@ -57,7 +57,6 @@ }, "dependencies": { "@ponder/utils": "^0.2.18", - "enssdk": "workspace:*", - "@ensdomains/address-encoder": "^1.1.2" + "enssdk": "workspace:*" } } diff --git a/packages/datasources/src/devnet/constants.ts b/packages/datasources/src/devnet/constants.ts index 4995028e0a..8886f15106 100644 --- a/packages/datasources/src/devnet/constants.ts +++ b/packages/datasources/src/devnet/constants.ts @@ -1,9 +1,4 @@ -import { type CoinName, getCoderByCoinName } from "@ensdomains/address-encoder"; -import { bytesToHex } from "@ensdomains/address-encoder/utils"; -import type { CoinType, NormalizedAddress } from "enssdk"; -import { asNormalizedAddress, toNormalizedAddress } from "enssdk"; -import type { Hex } from "viem"; -import { mnemonicToAccount } from "viem/accounts"; +import type { NormalizedAddress } from "enssdk"; /** * Deterministic contract addresses for the ENS contracts-v2 devnet used by ens-test-env. @@ -94,85 +89,3 @@ export const contracts = { MockUSDC: "0xfd471836031dc5108809d173a067e8486b9047a3", MockDAI: "0xcbeaf3bde82155f56486fb5a1072cb8baaf547cc", } as const satisfies Record; - -/** - * Must match the devnet mnemonic in contracts-v2 (Anvil named accounts). - * @see https://github.com/ensdomains/contracts-v2/blob/69bde1b345c47caf3d55a105b9f922280ba55f00/contracts/script/setup.ts#L56 - */ -const mnemonic = "test test test test test test test test test test test junk"; - -function createAccount(addressIndex: number, resolver: NormalizedAddress) { - const account = mnemonicToAccount(mnemonic, { addressIndex }); - return { - ...account, - address: toNormalizedAddress(account.address), - resolver, - }; -} - -/** - * Named accounts from the ens-test-env devnet. - * They are NOT real Ethereum Mainnet or testnet addresses. - * You can use `pnpm devnet` to see actual data in devnet - * - * @see https://github.com/ensdomains/ens-test-env - */ -export const accounts = { - deployer: createAccount(0, asNormalizedAddress("0x9c97ec2d79944fa55aa2eb6385bc8711cacf18d2")), - owner: createAccount(1, asNormalizedAddress("0x8550d35164e7f86bb6adf4cedb3f012913c9d563")), - user: createAccount(2, asNormalizedAddress("0x98a84b915ffe27241033ac8f29c6b7849a0fb6e4")), - user2: createAccount(3, asNormalizedAddress("0xd04f8f3726a417cfadeea604fc94cf66112b9af6")), -} as const; - -/** - * Fixtures for seeding the devnet with test data. - */ -export const addresses = { - one: asNormalizedAddress(`0x${"1".repeat(40)}`), -} as const satisfies Record; - -const getRawAddress = (coinName: CoinName, address: string) => { - const coder = getCoderByCoinName(coinName); - return { - coinType: coder.coinType, - raw: bytesToHex(coder.decode(address)), - address, - }; -}; - -/** - * Text records seeded on `test.eth` (PermissionedResolver) in the ens-test-env devnet. - * @see packages/integration-test-env/src/seed/resolver-records.ts - */ -export const testEthTextRecords = { - avatar: { key: "avatar", value: "https://example.com/avatar.png" }, - twitter: { key: "com.twitter", value: "ensdomains" }, - github: { key: "com.github", value: "@ensdomains" }, - x: { key: "com.x", value: "this_is_real_ensdomains_not_twitter_but_x_haha" }, - telegram: { key: "org.telegram", value: "t.me/ensdomains" }, - url: { key: "url", value: "https://ens.domains" }, - email: { key: "email", value: "test@ens.domains" }, - description: { key: "description", value: "test.eth" }, - header: { key: "header", value: "https://example.com/header.png" }, -} as const; - -const rawAddresses = { - bitcoin: getRawAddress("btc", "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"), - litecoin: getRawAddress("ltc", "LaMT348PWRnrqeeWArpwQPbuanpXDZGEUz"), - dogecoin: getRawAddress("doge", "DBXu2kgc3xtvCUWFcxFE3r9hEYgmuaaCyD"), - monacoin: getRawAddress("mona", "MHxgS2XMXjeJ4if2PRRbWYcdwZPWfdwaDT"), - rootstock: getRawAddress("rbtc", "0x5aaEB6053f3e94c9b9a09f33669435E7ef1bEAeD"), - binance: getRawAddress("bnb", "bnb1grpf0955h0ykzq3ar5nmum7y6gdfl6lxfn46h2"), - solana: getRawAddress("sol", "FncazAs6omJJjtLVzquzT9KoyXn6tFixr9kGjr42ktLj"), -} as const satisfies Record; - -export const fixtures = { - abiBytes: `0x${"01".repeat(32)}`, - fourBytesInterface: "0x11100111", - publicKeyX: `0x${"02".repeat(32)}`, - publicKeyY: `0x${"03".repeat(32)}`, - contenthash: `0x${"04".repeat(32)}`, - - rawAddresses: rawAddresses, - textRecords: testEthTextRecords, -} as const; diff --git a/packages/ensnode-sdk/package.json b/packages/ensnode-sdk/package.json index 35e3018076..98ff74b150 100644 --- a/packages/ensnode-sdk/package.json +++ b/packages/ensnode-sdk/package.json @@ -61,6 +61,7 @@ "viem": "catalog:" }, "devDependencies": { + "@ensnode/integration-test-env": "workspace:*", "@ensnode/shared-configs": "workspace:*", "@types/node": "catalog:", "tsup": "^8.3.6", diff --git a/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts b/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts index fce4501581..7e4dbfe747 100644 --- a/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts +++ b/packages/ensnode-sdk/src/omnigraph-api/example-queries.ts @@ -1,7 +1,7 @@ import { asInterpretedName, toNormalizedAddress } from "enssdk"; import { DatasourceNames, ENSNamespaceIds } from "@ensnode/datasources"; -import { accounts } from "@ensnode/datasources/devnet"; +import { accounts } from "@ensnode/integration-test-env/devnet"; import { getDatasourceContract } from "../shared/datasource-contract"; import type { NamespaceSpecificValue } from "../shared/namespace-specific-value"; diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index 886443d541..d4a3bca44e 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -413,18 +413,43 @@ input DomainPermissionsWhereInput { user: DomainPermissionsUserFilter } -"""An interpreted profile for a name.""" +""" +An interpreted profile for a name. Individual fields return null when their raw record is unset or cannot be interpreted; see each field's description for validation rules. +""" type DomainProfile { + """ + Interpreted multicoin address records on a Name profile. Each field returns null independently when its raw record cannot be interpreted. + """ addresses: ProfileAddresses + + """ + Interpreted avatar metadata. Returns null when the raw avatar record is unset or empty. + """ avatar: ProfileAvatar - """The profile description, or null when unset.""" + """ + The profile description. Returns null when the raw record is unset or empty. Non-empty values are returned as-is without format validation. + """ description: String - """The contact email address, or null when unset or invalid.""" + """ + The contact email address. Returns null when the raw record is unset, empty, or fails email validation. + """ email: Email + + """ + Interpreted header metadata. Returns null when the raw header record is unset or empty. + """ header: ProfileHeader + + """ + Interpreted social accounts on a Name profile. Each field returns null independently when its raw record cannot be interpreted. + """ socials: ProfileSocials + + """ + Interpreted website metadata. Returns null when the raw url record is unset, empty, or cannot be parsed as a valid http(s) URL. + """ website: ProfileWebsite } @@ -963,7 +988,7 @@ type ForwardResolve { acceleration: AccelerationStatus! """ - An interpreted profile for this name. Returns null when the name is not resolvable (non-canonical, unnormalized, or no profile field was selected). + An interpreted profile for this name. Returns null when the name is not resolvable (non-canonical, unnormalized, or no profile records were selected). """ profile: DomainProfile @@ -1256,46 +1281,70 @@ input PrimaryNamesWhereInput @oneOf { coinTypes: [CoinType!] } -"""Interpreted address records on a Name profile.""" +""" +Interpreted multicoin address records on a Name profile. Each field returns null independently when its raw record cannot be interpreted. +""" type ProfileAddresses { - """The interpreted Base address, or null when unset.""" + """ + The interpreted Base address. Returns null when the raw address record is unset, empty (`0x`), all-zero, not valid hex, or cannot be decoded and encoded for this coin type per ENSIP-9. + """ base: Address - """The interpreted Binance Chain (BNB) address, or null when unset.""" + """ + The interpreted Binance Chain (BNB) address. Returns null when the raw address record is unset, empty (`0x`), all-zero, not valid hex, or cannot be decoded and encoded for this coin type per ENSIP-9. + """ binance: BinanceAddress - """The interpreted Bitcoin address, or null when unset.""" + """ + The interpreted Bitcoin address. Returns null when the raw address record is unset, empty (`0x`), all-zero, not valid hex, or cannot be decoded and encoded for this coin type per ENSIP-9. + """ bitcoin: BitcoinAddress - """The interpreted Bitcoin Cash address, or null when unset.""" + """ + The interpreted Bitcoin Cash address. Returns null when the raw address record is unset, empty (`0x`), all-zero, not valid hex, or cannot be decoded and encoded for this coin type per ENSIP-9. + """ bitcoincash: BitcoinCashAddress - """The interpreted Dogecoin address, or null when unset.""" + """ + The interpreted Dogecoin address. Returns null when the raw address record is unset, empty (`0x`), all-zero, not valid hex, or cannot be decoded and encoded for this coin type per ENSIP-9. + """ dogecoin: DogecoinAddress - """The interpreted Ethereum address, or null when unset.""" + """ + The interpreted Ethereum address. Returns null when the raw address record is unset, empty (`0x`), all-zero, not valid hex, or cannot be decoded and encoded for this coin type per ENSIP-9. + """ ethereum: Address - """The interpreted Litecoin address, or null when unset.""" + """ + The interpreted Litecoin address. Returns null when the raw address record is unset, empty (`0x`), all-zero, not valid hex, or cannot be decoded and encoded for this coin type per ENSIP-9. + """ litecoin: LitecoinAddress - """The interpreted Monacoin address, or null when unset.""" + """ + The interpreted Monacoin address. Returns null when the raw address record is unset, empty (`0x`), all-zero, not valid hex, or cannot be decoded and encoded for this coin type per ENSIP-9. + """ monacoin: MonacoinAddress - """The interpreted Ripple (XRP) address, or null when unset.""" + """ + The interpreted Ripple (XRP) address. Returns null when the raw address record is unset, empty (`0x`), all-zero, not valid hex, or cannot be decoded and encoded for this coin type per ENSIP-9. + """ ripple: RippleAddress - """The interpreted Rootstock (RBTC) address, or null when unset.""" + """ + The interpreted Rootstock (RBTC) address. Returns null when the raw address record is unset, empty (`0x`), all-zero, not valid hex, or cannot be decoded and encoded for this coin type per ENSIP-9. + """ rootstock: RootstockAddress - """The interpreted Solana address, or null when unset.""" + """ + The interpreted Solana address. Returns null when the raw address record is unset, empty (`0x`), all-zero, not valid hex, or cannot be decoded and encoded for this coin type per ENSIP-9. + """ solana: SolanaAddress } """Interpreted avatar metadata on a Name profile.""" type ProfileAvatar { """ - Provides a HTTP-compatible URL for fetching the avatar image that can be safely referenced as an image in web browsers. This is an abstraction over the "raw" avatar record, which may reference non-HTTP compatible URLs or encodings including IPFS urls, CAIP-22 / CAIP-29 NFT References, and more edge cases that cannot be trivially referenced as an image in most web browsers. Additional details here: https://docs.ens.domains/ensip/12 + HTTP-compatible URL for fetching the avatar image in web browsers. Abstraction over the raw avatar record (IPFS, CAIP NFT references, etc.). Returns null when the raw value is not a direct http(s) URL and no fallback URL can be derived (including when the ENS Metadata Service is unavailable for this namespace). See https://docs.ens.domains/ensip/12. """ httpUrl: String } @@ -1303,32 +1352,59 @@ type ProfileAvatar { """Interpreted header metadata on a Name profile.""" type ProfileHeader { """ - Provides a HTTP-compatible URL for fetching the header image that can be safely referenced as an image in web browsers. This is an abstraction over the "raw" header record, which may reference non-HTTP compatible URLs or encodings including IPFS urls, CAIP-22 / CAIP-29 NFT References, and more edge cases that cannot be trivially referenced as an image in most web browsers. Additional details here: https://docs.ens.domains/ensip/12 + HTTP-compatible URL for fetching the header image in web browsers. Abstraction over the raw header record (IPFS, CAIP NFT references, etc.). Returns null when the raw value is not a direct http(s) URL and no fallback URL can be derived (including when the ENS Metadata Service is unavailable for this namespace). See https://docs.ens.domains/ensip/12. """ httpUrl: String } -"""An interpreted social account on a Name profile.""" +""" +An interpreted social account. Only returned when the raw record was successfully parsed; otherwise the parent social field is null. +""" type ProfileSocialAccount { - """The social handle.""" + """The normalized social handle extracted from the raw record.""" handle: String! - """The HTTP-compatible social profile URL.""" + """The canonical HTTP-compatible social profile URL.""" httpUrl: String! } -"""Interpreted social accounts on a Name profile.""" +""" +Interpreted social accounts on a Name profile. Each field returns null independently when its raw record cannot be interpreted. +""" type ProfileSocials { + """ + The interpreted GitHub account. Returns null when the raw record is unset, empty, or cannot be parsed as a GitHub handle or profile URL. + """ github: ProfileSocialAccount + + """ + The interpreted Keybase account. Returns null when the raw record is unset, empty, or cannot be parsed as a Keybase handle or profile URL. + """ keybase: ProfileSocialAccount + + """ + The interpreted LinkedIn account. Returns null when the raw record is unset, empty, or cannot be parsed as a LinkedIn handle or profile URL. + """ linkedin: ProfileSocialAccount + + """ + The interpreted Telegram account. Returns null when the raw record is unset, empty, or cannot be parsed as a Telegram handle or profile URL. + """ telegram: ProfileSocialAccount + + """ + The interpreted X (Twitter) account. Returns null when the raw record is unset, empty, or cannot be parsed as a X (Twitter) handle or profile URL. + """ twitter: ProfileSocialAccount } -"""Interpreted website metadata on a Name profile.""" +""" +Interpreted website metadata. Returns null when the raw url record is unset, empty, or cannot be parsed as a valid http(s) URL. +""" type ProfileWebsite { - """The HTTP-compatible website URL, or null when unset.""" + """ + The HTTP-compatible website URL. Returns null when the raw url record is unset, empty, not an http(s) URL, or cannot be parsed as a valid URL. + """ httpUrl: String } diff --git a/packages/integration-test-env/package.json b/packages/integration-test-env/package.json index fe442d61df..c35f41509b 100644 --- a/packages/integration-test-env/package.json +++ b/packages/integration-test-env/package.json @@ -5,16 +5,21 @@ "license": "MIT", "type": "module", "description": "Integration test environment orchestration for ENSNode", + "exports": { + "./devnet": "./src/devnet/index.ts" + }, "scripts": { "start": "tsx src/start.ts", "start:ci": "CI=1 tsx src/ci.ts", "typecheck": "tsc --noEmit" }, "dependencies": { - "@ensnode/shared-configs": "workspace:*", + "@ensdomains/address-encoder": "^1.1.2", "@ensnode/datasources": "workspace:*", "@ensnode/ensdb-sdk": "workspace:*", "@ensnode/ensnode-sdk": "workspace:*", + "@ensnode/shared-configs": "workspace:*", + "enssdk": "workspace:*", "execa": "^9.6.1", "testcontainers": "^12.0.1", "tsx": "^4.7.1", diff --git a/packages/integration-test-env/src/devnet/fixtures.ts b/packages/integration-test-env/src/devnet/fixtures.ts new file mode 100644 index 0000000000..dfc35c53d5 --- /dev/null +++ b/packages/integration-test-env/src/devnet/fixtures.ts @@ -0,0 +1,88 @@ +import { type CoinName, getCoderByCoinName } from "@ensdomains/address-encoder"; +import { bytesToHex } from "@ensdomains/address-encoder/utils"; +import type { CoinType, NormalizedAddress } from "enssdk"; +import { asNormalizedAddress, toNormalizedAddress } from "enssdk"; +import type { Hex } from "viem"; +import { mnemonicToAccount } from "viem/accounts"; + +/** + * Must match the devnet mnemonic in contracts-v2 (Anvil named accounts). + * @see https://github.com/ensdomains/contracts-v2/blob/69bde1b345c47caf3d55a105b9f922280ba55f00/contracts/script/setup.ts#L56 + */ +const mnemonic = "test test test test test test test test test test test junk"; + +function createAccount(addressIndex: number, resolver: NormalizedAddress) { + const account = mnemonicToAccount(mnemonic, { addressIndex }); + return { + ...account, + address: toNormalizedAddress(account.address), + resolver, + }; +} + +/** + * Named accounts from the ens-test-env devnet. + * They are NOT real Ethereum Mainnet or testnet addresses. + * You can use `pnpm devnet` to see actual data in devnet + * + * @see https://github.com/ensdomains/ens-test-env + */ +export const accounts = { + deployer: createAccount(0, asNormalizedAddress("0x9c97ec2d79944fa55aa2eb6385bc8711cacf18d2")), + owner: createAccount(1, asNormalizedAddress("0x8550d35164e7f86bb6adf4cedb3f012913c9d563")), + user: createAccount(2, asNormalizedAddress("0x98a84b915ffe27241033ac8f29c6b7849a0fb6e4")), + user2: createAccount(3, asNormalizedAddress("0xd04f8f3726a417cfadeea604fc94cf66112b9af6")), +} as const; + +/** + * Fixtures for seeding the devnet with test data. + */ +export const addresses = { + one: asNormalizedAddress(`0x${"1".repeat(40)}`), +} as const satisfies Record; + +const getRawAddress = (coinName: CoinName, address: string) => { + const coder = getCoderByCoinName(coinName); + return { + coinType: coder.coinType, + raw: bytesToHex(coder.decode(address)), + address, + }; +}; + +/** + * Text records seeded on `test.eth` (PermissionedResolver) in the ens-test-env devnet. + * @see packages/integration-test-env/src/seed/resolver-records.ts + */ +export const testEthTextRecords = { + avatar: { key: "avatar", value: "https://example.com/avatar.png" }, + twitter: { key: "com.twitter", value: "ensdomains" }, + github: { key: "com.github", value: "@ensdomains" }, + x: { key: "com.x", value: "this_is_real_ensdomains_not_twitter_but_x_haha" }, + telegram: { key: "org.telegram", value: "t.me/ensdomains" }, + url: { key: "url", value: "https://ens.domains" }, + email: { key: "email", value: "test@ens.domains" }, + description: { key: "description", value: "test.eth" }, + header: { key: "header", value: "https://example.com/header.png" }, +} as const; + +const rawAddresses = { + bitcoin: getRawAddress("btc", "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"), + litecoin: getRawAddress("ltc", "LaMT348PWRnrqeeWArpwQPbuanpXDZGEUz"), + dogecoin: getRawAddress("doge", "DBXu2kgc3xtvCUWFcxFE3r9hEYgmuaaCyD"), + monacoin: getRawAddress("mona", "MHxgS2XMXjeJ4if2PRRbWYcdwZPWfdwaDT"), + rootstock: getRawAddress("rbtc", "0x5aaEB6053f3e94c9b9a09f33669435E7ef1bEAeD"), + binance: getRawAddress("bnb", "bnb1grpf0955h0ykzq3ar5nmum7y6gdfl6lxfn46h2"), + solana: getRawAddress("sol", "FncazAs6omJJjtLVzquzT9KoyXn6tFixr9kGjr42ktLj"), +} as const satisfies Record; + +export const fixtures = { + abiBytes: `0x${"01".repeat(32)}`, + fourBytesInterface: "0x11100111", + publicKeyX: `0x${"02".repeat(32)}`, + publicKeyY: `0x${"03".repeat(32)}`, + contenthash: `0x${"04".repeat(32)}`, + + rawAddresses: rawAddresses, + textRecords: testEthTextRecords, +} as const; diff --git a/packages/integration-test-env/src/devnet/index.ts b/packages/integration-test-env/src/devnet/index.ts new file mode 100644 index 0000000000..d3eac7d8de --- /dev/null +++ b/packages/integration-test-env/src/devnet/index.ts @@ -0,0 +1 @@ +export * from "./fixtures"; diff --git a/packages/integration-test-env/src/seed/index.ts b/packages/integration-test-env/src/seed/index.ts index b8d916bca1..b40198f010 100644 --- a/packages/integration-test-env/src/seed/index.ts +++ b/packages/integration-test-env/src/seed/index.ts @@ -11,8 +11,8 @@ import { } from "viem"; import { ensTestEnvChain } from "@ensnode/datasources"; -import { accounts } from "@ensnode/datasources/devnet"; +import { accounts } from "../devnet/fixtures"; import { seedPrimaryNameRecords } from "./primary-names"; import { seedResolverRecords } from "./resolver-records"; diff --git a/packages/integration-test-env/src/seed/resolver-records.ts b/packages/integration-test-env/src/seed/resolver-records.ts index 0a08edbc95..2ee7f3cb3c 100644 --- a/packages/integration-test-env/src/seed/resolver-records.ts +++ b/packages/integration-test-env/src/seed/resolver-records.ts @@ -2,8 +2,9 @@ import { type Address, type Hex, namehash, toHex } from "viem"; import { packetToBytes } from "viem/ens"; import { ResolverABI, UniversalResolverABI } from "@ensnode/datasources"; -import { addresses, contracts, fixtures } from "@ensnode/datasources/devnet"; +import { contracts } from "@ensnode/datasources/devnet"; +import { addresses, fixtures } from "../devnet/fixtures"; import type { DevnetWalletClient, DevnetWalletClients } from "./index"; import { waitForTransactionReceipt } from "./index"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d86ed09e69..31a6176808 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -481,6 +481,9 @@ importers: specifier: 'catalog:' version: 4.3.6 devDependencies: + '@ensnode/integration-test-env': + specifier: workspace:* + version: link:../../packages/integration-test-env '@ensnode/shared-configs': specifier: workspace:* version: link:../../packages/shared-configs @@ -935,9 +938,6 @@ importers: packages/datasources: dependencies: - '@ensdomains/address-encoder': - specifier: ^1.1.2 - version: 1.1.4 '@ponder/utils': specifier: ^0.2.18 version: 0.2.18(typescript@5.9.3)(viem@2.50.3(typescript@5.9.3)(zod@4.3.6)) @@ -1103,6 +1103,9 @@ importers: specifier: 'catalog:' version: 4.3.6 devDependencies: + '@ensnode/integration-test-env': + specifier: workspace:* + version: link:../integration-test-env '@ensnode/shared-configs': specifier: workspace:* version: link:../shared-configs @@ -1188,6 +1191,9 @@ importers: packages/integration-test-env: dependencies: + '@ensdomains/address-encoder': + specifier: ^1.1.2 + version: 1.1.4 '@ensnode/datasources': specifier: workspace:* version: link:../datasources @@ -1200,6 +1206,9 @@ importers: '@ensnode/shared-configs': specifier: workspace:* version: link:../shared-configs + enssdk: + specifier: workspace:* + version: link:../enssdk execa: specifier: ^9.6.1 version: 9.6.1 @@ -15108,6 +15117,14 @@ snapshots: chai: 6.2.0 tinyrainbow: 3.0.3 + '@vitest/mocker@4.0.5(vite@6.4.2(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.20.6)(yaml@2.8.3))': + dependencies: + '@vitest/spy': 4.0.5 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.4.2(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.20.6)(yaml@2.8.3) + '@vitest/mocker@4.0.5(vite@6.4.2(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@vitest/spy': 4.0.5 @@ -20756,7 +20773,7 @@ snapshots: vitest@4.0.5(@types/debug@4.1.12)(@types/node@24.10.9)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.12))(lightningcss@1.32.0)(tsx@4.20.6)(yaml@2.8.3): dependencies: '@vitest/expect': 4.0.5 - '@vitest/mocker': 4.0.5(vite@6.4.2(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.8.3)) + '@vitest/mocker': 4.0.5(vite@6.4.2(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.20.6)(yaml@2.8.3)) '@vitest/pretty-format': 4.0.5 '@vitest/runner': 4.0.5 '@vitest/snapshot': 4.0.5 From 863bfb456ce26455a810f00f35d5ace233adc6d0 Mon Sep 17 00:00:00 2001 From: sevenzing Date: Wed, 3 Jun 2026 14:09:08 +0300 Subject: [PATCH 16/19] rename parser to interpreter --- .../lib/resolution/profile/README.md | 106 +++++++++--------- .../profile/build-profile-selection.ts | 34 +++--- .../addresses.test.ts | 20 ++-- .../profile/interpreters/addresses.ts | 80 +++++++++++++ .../{parsers => interpreters}/images.test.ts | 18 +-- .../{parsers => interpreters}/images.ts | 16 +-- .../resolution/profile/interpreters/index.ts | 33 ++++++ .../{parsers => interpreters}/social.test.ts | 66 +++++------ .../{parsers => interpreters}/social.ts | 99 ++++++++-------- .../{parsers => interpreters}/test-helpers.ts | 0 .../{parsers => interpreters}/texts.test.ts | 38 ++++--- .../{parsers => interpreters}/texts.ts | 19 ++-- .../{parsers => interpreters}/types.ts | 10 +- .../resolution/profile/parsers/addresses.ts | 76 ------------- .../lib/resolution/profile/parsers/index.ts | 29 ----- .../src/omnigraph-api/schema/profile.ts | 60 +++++----- 16 files changed, 362 insertions(+), 342 deletions(-) rename apps/ensapi/src/omnigraph-api/lib/resolution/profile/{parsers => interpreters}/addresses.test.ts (80%) create mode 100644 apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/addresses.ts rename apps/ensapi/src/omnigraph-api/lib/resolution/profile/{parsers => interpreters}/images.test.ts (74%) rename apps/ensapi/src/omnigraph-api/lib/resolution/profile/{parsers => interpreters}/images.ts (76%) create mode 100644 apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/index.ts rename apps/ensapi/src/omnigraph-api/lib/resolution/profile/{parsers => interpreters}/social.test.ts (81%) rename apps/ensapi/src/omnigraph-api/lib/resolution/profile/{parsers => interpreters}/social.ts (64%) rename apps/ensapi/src/omnigraph-api/lib/resolution/profile/{parsers => interpreters}/test-helpers.ts (100%) rename apps/ensapi/src/omnigraph-api/lib/resolution/profile/{parsers => interpreters}/texts.test.ts (58%) rename apps/ensapi/src/omnigraph-api/lib/resolution/profile/{parsers => interpreters}/texts.ts (60%) rename apps/ensapi/src/omnigraph-api/lib/resolution/profile/{parsers => interpreters}/types.ts (57%) delete mode 100644 apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/addresses.ts delete mode 100644 apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/index.ts diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/README.md b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/README.md index d5f0496a05..961a9abd45 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/README.md +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/README.md @@ -1,15 +1,15 @@ # Name profile resolution -Interpreted ENS profile fields exposed on `Domain.resolve.profile` (and `PrimaryNameRecord.resolve.profile`) in the Omnigraph API. Raw resolver records are resolved in one round-trip; each GraphQL field is backed by a `ProfileFieldParser` that declares its record selection and parsing logic. +Interpreted ENS profile fields exposed on `Domain.resolve.profile` (and `PrimaryNameRecord.resolve.profile`) in the Omnigraph API. Raw resolver records are resolved in one round-trip; each GraphQL field is backed by a `ProfileFieldInterpreter` that declares its record selection and interpretation logic. ## Architecture ``` profile/ build-profile-selection.ts # GraphQL selection → ResolverRecordsSelection - parsers/ - types.ts # ProfileFieldParser - texts.ts # Simple text passthrough parsers + interpreters/ + types.ts # ProfileFieldInterpreter + texts.ts # Simple text passthrough interpreters images.ts # avatar / header → httpUrl (direct HTTP + metadata service) social.ts # Service keys → { handle, httpUrl } addresses.ts # Multicoin addresses → typed address strings @@ -18,62 +18,62 @@ profile/ GraphQL wiring lives in `apps/ensapi/src/omnigraph-api/schema/profile.ts`. -Each parser is a singleton with: +Each interpreter is a singleton with: - `selection` — which text keys / coin types must be fetched -- `parse(result)` — derive the GraphQL output from `ResolvedRecordsModel` +- `interpret(result)` — derive the GraphQL output from `ResolvedRecordsModel` -`buildProfileSelectionFromResolveContainerInfo` merges parser selections based on the client's `profile { ... }` sub-selection. +`buildProfileSelectionFromResolveContainerInfo` merges interpreter selections based on the client's `profile { ... }` sub-selection. ## Records roadmap Record names use a `texts.` prefix for ENS text records and `addresses.` for multicoin address records. GraphQL output paths are noted in the description where they differ from the on-chain key. -| Record name | Status | ENSIP | Description | -| ------------------------------------------------- | ------ | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `texts.description` | ✅ | [18](https://docs.ens.domains/ensip/18), [5](https://docs.ens.domains/ensip/5) | Biography text. GraphQL: `profile.description`. Parser: `ProfileDescriptionParser`. | -| `texts.avatar` | ✅ | [18](https://docs.ens.domains/ensip/18), [12](https://docs.ens.domains/ensip/12) | Avatar image ([ENSIP-12](https://docs.ens.domains/ensip/12)). GraphQL: `profile.avatar.httpUrl`. Direct `http(s)://` or ENS Metadata Service fallback. Parser: `ProfileAvatarParser`. | -| `texts.header` | ✅ | [18](https://docs.ens.domains/ensip/18), [12](https://docs.ens.domains/ensip/12) | Header / banner image ([ENSIP-12](https://docs.ens.domains/ensip/12)). GraphQL: `profile.header.httpUrl`. Parser: `ProfileHeaderParser`. | -| `texts.url` | ✅ | [18](https://docs.ens.domains/ensip/18), [5](https://docs.ens.domains/ensip/5) | Website URL. GraphQL: `profile.website.httpUrl`. Parser: `ProfileWebsiteParser`. | -| `texts.com.github` | ✅ | [18](https://docs.ens.domains/ensip/18), [5](https://docs.ens.domains/ensip/5) | GitHub username or repo URL. GraphQL: `profile.socials.github`. Parser: `SocialGithubParser`. | -| `texts.com.x` | ✅ | [18](https://docs.ens.domains/ensip/18), [5](https://docs.ens.domains/ensip/5) | X handle or URL. GraphQL: `profile.socials.twitter`. Parser: `SocialTwitterParser` (preferred over `com.twitter`). | -| `texts.com.twitter` | ✅ | [18](https://docs.ens.domains/ensip/18), [5](https://docs.ens.domains/ensip/5) | Twitter handle or URL. Fallback when `com.x` is unset. GraphQL: `profile.socials.twitter`. Parser: `SocialTwitterParser`. | -| `texts.org.telegram` | ✅ | [18](https://docs.ens.domains/ensip/18), [5](https://docs.ens.domains/ensip/5) | Telegram handle or URL. GraphQL: `profile.socials.telegram`. Parser: `SocialTelegramParser`. | -| `addresses.ethereum` | ✅ | [9](https://docs.ens.domains/ensip/9) | Ethereum address (`coinType` 60). GraphQL: `profile.addresses.ethereum`. Parser: `ProfileAddressEthereumParser`. | -| `addresses.base` | ✅ | [11](https://docs.ens.domains/ensip/11) | Base address (`coinType` 2147492101). GraphQL: `profile.addresses.base`. Parser: `ProfileAddressBaseParser`. | -| `addresses.bitcoin` | ✅ | [9](https://docs.ens.domains/ensip/9) | Bitcoin address (`coinType` 0). GraphQL: `profile.addresses.bitcoin`. Parser: `ProfileAddressBitcoinParser`. | -| `addresses.solana` | ✅ | [9](https://docs.ens.domains/ensip/9) | Solana address (`coinType` 501). GraphQL: `profile.addresses.solana`. Parser: `ProfileAddressSolanaParser`. | -| `texts.theme` | 📋 | [18](https://docs.ens.domains/ensip/18) | Comma-separated hex colour scheme (`background,text,accent,accentText,border`). | -| `texts.email` | ✅ | [18](https://docs.ens.domains/ensip/18), [5](https://docs.ens.domains/ensip/5) | Contact email address (validated; invalid values → null). GraphQL: `profile.email` (`Email` scalar). Parser: `ProfileEmailParser`. | -| `texts.location` | 📋 | [18](https://docs.ens.domains/ensip/18), [5](https://docs.ens.domains/ensip/5) | Human-readable location (e.g. `Melbourne, Australia`). | -| `texts.timezone` | 📋 | [18](https://docs.ens.domains/ensip/18) | tz database timezone (e.g. `Australia/Melbourne`). | -| `texts.language` | 📋 | [18](https://docs.ens.domains/ensip/18) | ISO 639-1 two-letter language code. | -| `texts.primary-contact` | 📋 | [18](https://docs.ens.domains/ensip/18) | Record key of the primary contact (e.g. `com.github`, `email`). | -| `texts.keywords` | 📋 | [5](https://docs.ens.domains/ensip/5) | Comma-separated keywords, most significant first. | -| `texts.mail` | 📋 | [5](https://docs.ens.domains/ensip/5) | Physical mailing address. | -| `texts.notice` | 📋 | [5](https://docs.ens.domains/ensip/5) | Notice regarding the name. | -| `texts.phone` | 📋 | [5](https://docs.ens.domains/ensip/5) | Phone number as E.164 string. | -| `texts.com.linkedin` | ✅ | [5](https://docs.ens.domains/ensip/5) | LinkedIn username or profile URL. GraphQL: `profile.socials.linkedin`. Parser: `SocialLinkedInParser`. | -| `texts.com.peepeth` | 📋 | [5](https://docs.ens.domains/ensip/5) | Peepeth username. | -| `texts.io.keybase` | ✅ | [5](https://docs.ens.domains/ensip/5) | Keybase username or profile URL. GraphQL: `profile.socials.keybase`. Parser: `SocialKeybaseParser`. | -| `texts.vnd.github` | ✅ | [5](https://docs.ens.domains/ensip/5) | Legacy GitHub key (renamed to `com.github`). Fallback when `com.github` is unset. Parser: `SocialGithubParser`. | -| `texts.vnd.twitter` | ✅ | [5](https://docs.ens.domains/ensip/5) | Legacy Twitter key (renamed to `com.twitter`). Fallback when `com.x` and `com.twitter` are unset. Parser: `SocialTwitterParser`. | -| `texts.vnd.peepeth` | 📋 | [5](https://docs.ens.domains/ensip/5) | Legacy Peepeth key (renamed to `com.peepeth`). Planned as fallback when `com.peepeth` is unset. | -| `contenthash` | 📋 | [7](https://docs.ens.domains/ensip/7) | IPFS / Swarm content address. Resolved on `records.contenthash` today; not yet exposed on `profile`. | -| `texts.agent-registration[][]` | 📋 | [25](https://docs.ens.domains/ensip/25) | Non-empty attestation linking an ENS name to an on-chain AI agent registry entry. | -| `texts.agent-context` | 📋 | [26](https://docs.ens.domains/ensip/26) | Agent description and discovery entry point (plain text, Markdown, YAML, JSON, …). | -| `texts.agent-endpoint[mcp]` | 📋 | [26](https://docs.ens.domains/ensip/26) | Model Context Protocol endpoint URL. | -| `texts.agent-endpoint[a2a]` | 📋 | [26](https://docs.ens.domains/ensip/26) | Agent-to-Agent protocol endpoint URL. | -| `texts.agent-endpoint[web]` | 📋 | [26](https://docs.ens.domains/ensip/26) | Human-facing web interface URL. | -| `texts.alias` | ➖ | [18](https://docs.ens.domains/ensip/18) | Display alias. Not planned — ENSv2 will define aliases differently. | -| `texts.name` | ➖ | [18](https://docs.ens.domains/ensip/18) | Legacy alias key superseded by `alias`. Not planned — ENSv2 will define aliases differently. | -| `texts.display` | ➖ | [5](https://docs.ens.domains/ensip/5) | Canonical display name. Not planned. | -| `addresses.litecoin` | ✅ | [9](https://docs.ens.domains/ensip/9) | Litecoin address (`coinType` 2). GraphQL: `profile.addresses.litecoin`. Parser: `ProfileAddressLitecoinParser`. | -| `addresses.dogecoin` | ✅ | [9](https://docs.ens.domains/ensip/9) | Dogecoin address (`coinType` 3). GraphQL: `profile.addresses.dogecoin`. Parser: `ProfileAddressDogecoinParser`. | -| `addresses.monacoin` | ✅ | [9](https://docs.ens.domains/ensip/9) | Monacoin address (`coinType` 22). GraphQL: `profile.addresses.monacoin`. Parser: `ProfileAddressMonacoinParser`. | -| `addresses.rootstock` | ✅ | [9](https://docs.ens.domains/ensip/9) | Rootstock (RBTC) address (`coinType` 137). EIP-55 checksummed. GraphQL: `profile.addresses.rootstock`. Parser: `ProfileAddressRootstockParser`. | -| `addresses.ripple` | ✅ | [9](https://docs.ens.domains/ensip/9) | Ripple (XRP) address (`coinType` 144). GraphQL: `profile.addresses.ripple`. Parser: `ProfileAddressRippleParser`. | -| `addresses.bitcoincash` | ✅ | [9](https://docs.ens.domains/ensip/9) | Bitcoin Cash address (`coinType` 145). CashAddr format. GraphQL: `profile.addresses.bitcoincash`. Parser: `ProfileAddressBitcoinCashParser`. | -| `addresses.binance` | ✅ | [9](https://docs.ens.domains/ensip/9) | Binance Chain (BNB) address (`coinType` 714). Bech32. GraphQL: `profile.addresses.binance`. Parser: `ProfileAddressBinanceParser`. | +| Record name | Status | ENSIP | Description | +| ------------------------------------------------- | ------ | -------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `texts.description` | ✅ | [18](https://docs.ens.domains/ensip/18), [5](https://docs.ens.domains/ensip/5) | Biography text. GraphQL: `profile.description`. Interpreter: `ProfileDescriptionInterpreter`. | +| `texts.avatar` | ✅ | [18](https://docs.ens.domains/ensip/18), [12](https://docs.ens.domains/ensip/12) | Avatar image ([ENSIP-12](https://docs.ens.domains/ensip/12)). GraphQL: `profile.avatar.httpUrl`. Direct `http(s)://` or ENS Metadata Service fallback. Interpreter: `ProfileAvatarInterpreter`. | +| `texts.header` | ✅ | [18](https://docs.ens.domains/ensip/18), [12](https://docs.ens.domains/ensip/12) | Header / banner image ([ENSIP-12](https://docs.ens.domains/ensip/12)). GraphQL: `profile.header.httpUrl`. Interpreter: `ProfileHeaderInterpreter`. | +| `texts.url` | ✅ | [18](https://docs.ens.domains/ensip/18), [5](https://docs.ens.domains/ensip/5) | Website URL. GraphQL: `profile.website.httpUrl`. Interpreter: `ProfileWebsiteInterpreter`. | +| `texts.com.github` | ✅ | [18](https://docs.ens.domains/ensip/18), [5](https://docs.ens.domains/ensip/5) | GitHub username or repo URL. GraphQL: `profile.socials.github`. Interpreter: `SocialGithubInterpreter`. | +| `texts.com.x` | ✅ | [18](https://docs.ens.domains/ensip/18), [5](https://docs.ens.domains/ensip/5) | X handle or URL. GraphQL: `profile.socials.twitter`. Interpreter: `SocialTwitterInterpreter` (preferred over `com.twitter`). | +| `texts.com.twitter` | ✅ | [18](https://docs.ens.domains/ensip/18), [5](https://docs.ens.domains/ensip/5) | Twitter handle or URL. Fallback when `com.x` is unset. GraphQL: `profile.socials.twitter`. Interpreter: `SocialTwitterInterpreter`. | +| `texts.org.telegram` | ✅ | [18](https://docs.ens.domains/ensip/18), [5](https://docs.ens.domains/ensip/5) | Telegram handle or URL. GraphQL: `profile.socials.telegram`. Interpreter: `SocialTelegramInterpreter`. | +| `addresses.ethereum` | ✅ | [9](https://docs.ens.domains/ensip/9) | Ethereum address (`coinType` 60). GraphQL: `profile.addresses.ethereum`. Interpreter: `ProfileAddressEthereumInterpreter`. | +| `addresses.base` | ✅ | [11](https://docs.ens.domains/ensip/11) | Base address (`coinType` 2147492101). GraphQL: `profile.addresses.base`. Interpreter: `ProfileAddressBaseInterpreter`. | +| `addresses.bitcoin` | ✅ | [9](https://docs.ens.domains/ensip/9) | Bitcoin address (`coinType` 0). GraphQL: `profile.addresses.bitcoin`. Interpreter: `ProfileAddressBitcoinInterpreter`. | +| `addresses.solana` | ✅ | [9](https://docs.ens.domains/ensip/9) | Solana address (`coinType` 501). GraphQL: `profile.addresses.solana`. Interpreter: `ProfileAddressSolanaInterpreter`. | +| `texts.theme` | 📋 | [18](https://docs.ens.domains/ensip/18) | Comma-separated hex colour scheme (`background,text,accent,accentText,border`). | +| `texts.email` | ✅ | [18](https://docs.ens.domains/ensip/18), [5](https://docs.ens.domains/ensip/5) | Contact email address (validated; invalid values → null). GraphQL: `profile.email` (`Email` scalar). Interpreter: `ProfileEmailInterpreter`. | +| `texts.location` | 📋 | [18](https://docs.ens.domains/ensip/18), [5](https://docs.ens.domains/ensip/5) | Human-readable location (e.g. `Melbourne, Australia`). | +| `texts.timezone` | 📋 | [18](https://docs.ens.domains/ensip/18) | tz database timezone (e.g. `Australia/Melbourne`). | +| `texts.language` | 📋 | [18](https://docs.ens.domains/ensip/18) | ISO 639-1 two-letter language code. | +| `texts.primary-contact` | 📋 | [18](https://docs.ens.domains/ensip/18) | Record key of the primary contact (e.g. `com.github`, `email`). | +| `texts.keywords` | 📋 | [5](https://docs.ens.domains/ensip/5) | Comma-separated keywords, most significant first. | +| `texts.mail` | 📋 | [5](https://docs.ens.domains/ensip/5) | Physical mailing address. | +| `texts.notice` | 📋 | [5](https://docs.ens.domains/ensip/5) | Notice regarding the name. | +| `texts.phone` | 📋 | [5](https://docs.ens.domains/ensip/5) | Phone number as E.164 string. | +| `texts.com.linkedin` | ✅ | [5](https://docs.ens.domains/ensip/5) | LinkedIn username or profile URL. GraphQL: `profile.socials.linkedin`. Interpreter: `SocialLinkedInInterpreter`. | +| `texts.com.peepeth` | 📋 | [5](https://docs.ens.domains/ensip/5) | Peepeth username. | +| `texts.io.keybase` | ✅ | [5](https://docs.ens.domains/ensip/5) | Keybase username or profile URL. GraphQL: `profile.socials.keybase`. Interpreter: `SocialKeybaseInterpreter`. | +| `texts.vnd.github` | ✅ | [5](https://docs.ens.domains/ensip/5) | Legacy GitHub key (renamed to `com.github`). Fallback when `com.github` is unset. Interpreter: `SocialGithubInterpreter`. | +| `texts.vnd.twitter` | ✅ | [5](https://docs.ens.domains/ensip/5) | Legacy Twitter key (renamed to `com.twitter`). Fallback when `com.x` and `com.twitter` are unset. Interpreter: `SocialTwitterInterpreter`. | +| `texts.vnd.peepeth` | 📋 | [5](https://docs.ens.domains/ensip/5) | Legacy Peepeth key (renamed to `com.peepeth`). Planned as fallback when `com.peepeth` is unset. | +| `contenthash` | 📋 | [7](https://docs.ens.domains/ensip/7) | IPFS / Swarm content address. Resolved on `records.contenthash` today; not yet exposed on `profile`. | +| `texts.agent-registration[][]` | 📋 | [25](https://docs.ens.domains/ensip/25) | Non-empty attestation linking an ENS name to an on-chain AI agent registry entry. | +| `texts.agent-context` | 📋 | [26](https://docs.ens.domains/ensip/26) | Agent description and discovery entry point (plain text, Markdown, YAML, JSON, …). | +| `texts.agent-endpoint[mcp]` | 📋 | [26](https://docs.ens.domains/ensip/26) | Model Context Protocol endpoint URL. | +| `texts.agent-endpoint[a2a]` | 📋 | [26](https://docs.ens.domains/ensip/26) | Agent-to-Agent protocol endpoint URL. | +| `texts.agent-endpoint[web]` | 📋 | [26](https://docs.ens.domains/ensip/26) | Human-facing web interface URL. | +| `texts.alias` | ➖ | [18](https://docs.ens.domains/ensip/18) | Display alias. Not planned — ENSv2 will define aliases differently. | +| `texts.name` | ➖ | [18](https://docs.ens.domains/ensip/18) | Legacy alias key superseded by `alias`. Not planned — ENSv2 will define aliases differently. | +| `texts.display` | ➖ | [5](https://docs.ens.domains/ensip/5) | Canonical display name. Not planned. | +| `addresses.litecoin` | ✅ | [9](https://docs.ens.domains/ensip/9) | Litecoin address (`coinType` 2). GraphQL: `profile.addresses.litecoin`. Interpreter: `ProfileAddressLitecoinInterpreter`. | +| `addresses.dogecoin` | ✅ | [9](https://docs.ens.domains/ensip/9) | Dogecoin address (`coinType` 3). GraphQL: `profile.addresses.dogecoin`. Interpreter: `ProfileAddressDogecoinInterpreter`. | +| `addresses.monacoin` | ✅ | [9](https://docs.ens.domains/ensip/9) | Monacoin address (`coinType` 22). GraphQL: `profile.addresses.monacoin`. Interpreter: `ProfileAddressMonacoinInterpreter`. | +| `addresses.rootstock` | ✅ | [9](https://docs.ens.domains/ensip/9) | Rootstock (RBTC) address (`coinType` 137). EIP-55 checksummed. GraphQL: `profile.addresses.rootstock`. Interpreter: `ProfileAddressRootstockInterpreter`. | +| `addresses.ripple` | ✅ | [9](https://docs.ens.domains/ensip/9) | Ripple (XRP) address (`coinType` 144). GraphQL: `profile.addresses.ripple`. Interpreter: `ProfileAddressRippleInterpreter`. | +| `addresses.bitcoincash` | ✅ | [9](https://docs.ens.domains/ensip/9) | Bitcoin Cash address (`coinType` 145). CashAddr format. GraphQL: `profile.addresses.bitcoincash`. Interpreter: `ProfileAddressBitcoinCashInterpreter`. | +| `addresses.binance` | ✅ | [9](https://docs.ens.domains/ensip/9) | Binance Chain (BNB) address (`coinType` 714). Bech32. GraphQL: `profile.addresses.binance`. Interpreter: `ProfileAddressBinanceInterpreter`. | **Status legend:** ✅ done · 📋 planned · ➖ not planned diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/build-profile-selection.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/build-profile-selection.ts index 25f63f7197..625d41c33b 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/build-profile-selection.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/build-profile-selection.ts @@ -8,14 +8,14 @@ import { } from "@/omnigraph-api/lib/resolution/records-selection"; import { - ADDRESS_PARSERS, - ProfileAvatarParser, - ProfileDescriptionParser, - ProfileEmailParser, - ProfileHeaderParser, - ProfileWebsiteParser, - SOCIAL_PARSERS, -} from "./parsers"; + ADDRESS_INTERPRETERS, + ProfileAvatarInterpreter, + ProfileDescriptionInterpreter, + ProfileEmailInterpreter, + ProfileHeaderInterpreter, + ProfileWebsiteInterpreter, + SOCIAL_INTERPRETERS, +} from "./interpreters"; /** Collect all FieldNodes named `fieldName` within a set of parent FieldNodes. */ function collectSubFieldNodes( @@ -82,28 +82,28 @@ export function buildProfileSelectionFromResolveContainerInfo( const topLevelFields = collectChildFieldNames(profileNodes, info); if (topLevelFields.has("description")) { - merged = mergeRecordsSelections(merged, ProfileDescriptionParser.selection); + merged = mergeRecordsSelections(merged, ProfileDescriptionInterpreter.selection); } if (topLevelFields.has("avatar")) { - merged = mergeRecordsSelections(merged, ProfileAvatarParser.selection); + merged = mergeRecordsSelections(merged, ProfileAvatarInterpreter.selection); } if (topLevelFields.has("header")) { - merged = mergeRecordsSelections(merged, ProfileHeaderParser.selection); + merged = mergeRecordsSelections(merged, ProfileHeaderInterpreter.selection); } if (topLevelFields.has("website")) { - merged = mergeRecordsSelections(merged, ProfileWebsiteParser.selection); + merged = mergeRecordsSelections(merged, ProfileWebsiteInterpreter.selection); } if (topLevelFields.has("email")) { - merged = mergeRecordsSelections(merged, ProfileEmailParser.selection); + merged = mergeRecordsSelections(merged, ProfileEmailInterpreter.selection); } // 3. Walk socials sub-fields const socialsNodes = collectSubFieldNodes(profileNodes, "socials", info); if (socialsNodes.length > 0) { const socialFields = collectChildFieldNames(socialsNodes, info); - for (const [fieldName, parser] of Object.entries(SOCIAL_PARSERS)) { + for (const [fieldName, interpreter] of Object.entries(SOCIAL_INTERPRETERS)) { if (socialFields.has(fieldName)) { - merged = mergeRecordsSelections(merged, parser.selection); + merged = mergeRecordsSelections(merged, interpreter.selection); } } } @@ -112,9 +112,9 @@ export function buildProfileSelectionFromResolveContainerInfo( const addressesNodes = collectSubFieldNodes(profileNodes, "addresses", info); if (addressesNodes.length > 0) { const addressFields = collectChildFieldNames(addressesNodes, info); - for (const [fieldName, parser] of Object.entries(ADDRESS_PARSERS)) { + for (const [fieldName, interpreter] of Object.entries(ADDRESS_INTERPRETERS)) { if (addressFields.has(fieldName)) { - merged = mergeRecordsSelections(merged, parser.selection); + merged = mergeRecordsSelections(merged, interpreter.selection); } } } diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/addresses.test.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/addresses.test.ts similarity index 80% rename from apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/addresses.test.ts rename to apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/addresses.test.ts index e3c134f1c0..60baee4e2b 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/addresses.test.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/addresses.test.ts @@ -1,10 +1,10 @@ import type { Hex } from "viem"; import { describe, expect, it } from "vitest"; -import { ADDRESS_PARSERS } from "./addresses"; +import { ADDRESS_INTERPRETERS } from "./addresses"; import { profileRecordsModel } from "./test-helpers"; -describe("ADDRESS_PARSERS", () => { +describe("ADDRESS_INTERPRETERS", () => { it.each([ [ "ethereum", @@ -74,10 +74,10 @@ describe("ADDRESS_PARSERS", () => { "bnb1grpf0955h0ykzq3ar5nmum7y6gdfl6lxfn46h2", ], ] as const)("parses %s address", (field, coinType, raw, expected) => { - expect(ADDRESS_PARSERS[field].selection).toEqual({ addresses: [coinType] }); - expect(ADDRESS_PARSERS[field].parse(profileRecordsModel({}, { [coinType]: raw }))).toBe( - expected, - ); + expect(ADDRESS_INTERPRETERS[field].selection).toEqual({ addresses: [coinType] }); + expect( + ADDRESS_INTERPRETERS[field].interpret(profileRecordsModel({}, { [coinType]: raw })), + ).toBe(expected); }); it.each([ @@ -86,11 +86,11 @@ describe("ADDRESS_PARSERS", () => { ["0x sentinel", "0x"], ["non-hex value", "0xnot-hex"], ] as const)("returns null: %s (%s)", (_message, raw) => { - for (const [field, parser] of Object.entries(ADDRESS_PARSERS)) { - const coinType = parser.selection.addresses?.[0]; - if (coinType == null) throw new Error(`Coin type not found for parser ${field}`); + for (const [field, interpreter] of Object.entries(ADDRESS_INTERPRETERS)) { + const coinType = interpreter.selection.addresses?.[0]; + if (coinType == null) throw new Error(`Coin type not found for interpreter ${field}`); const model = raw === undefined ? {} : { [coinType]: raw as Hex }; - expect(parser.parse(profileRecordsModel({}, model))).toBeNull(); + expect(interpreter.interpret(profileRecordsModel({}, model))).toBeNull(); } }); }); diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/addresses.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/addresses.ts new file mode 100644 index 0000000000..e5d9d3732b --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/addresses.ts @@ -0,0 +1,80 @@ +import { type CoinName, getCoderByCoinName } from "@ensdomains/address-encoder"; +import { + type BinanceAddress, + type BitcoinAddress, + type BitcoinCashAddress, + type CoinType, + type DogecoinAddress, + type LitecoinAddress, + type MonacoinAddress, + type RippleAddress, + type RootstockAddress, + type SolanaAddress, + toNormalizedAddress, +} from "enssdk"; +import { isHex, toBytes } from "viem"; + +import type { ProfileFieldInterpreter } from "./types"; + +const buildAddressInterpreter = ( + coinNameOrType: CoinName, + format?: (encoded: string) => T, +): ProfileFieldInterpreter => { + const coder = getCoderByCoinName(coinNameOrType); + const coinType = coder.coinType as CoinType; + + return { + selection: { addresses: [coinType] }, + interpret: (result) => { + const raw = result.records.addresses?.[coinType]; + if (raw == null || raw === "0x") return null; + if (!isHex(raw)) return null; + + try { + const bytes = toBytes(raw); + if (bytes.length === 0 || bytes.every((byte) => byte === 0)) return null; + + const encoded = coder.encode(bytes); + + if (format) { + return format(encoded); + } + + return encoded as T; + } catch { + return null; + } + }, + }; +}; + +export const ProfileAddressEthereumInterpreter = buildAddressInterpreter( + "eth", + toNormalizedAddress, +); +export const ProfileAddressBaseInterpreter = buildAddressInterpreter("base", toNormalizedAddress); +export const ProfileAddressBitcoinInterpreter = buildAddressInterpreter("btc"); +export const ProfileAddressSolanaInterpreter = buildAddressInterpreter("sol"); +export const ProfileAddressLitecoinInterpreter = buildAddressInterpreter("ltc"); +export const ProfileAddressDogecoinInterpreter = buildAddressInterpreter("doge"); +export const ProfileAddressMonacoinInterpreter = buildAddressInterpreter("mona"); + +export const ProfileAddressRootstockInterpreter = buildAddressInterpreter("rbtc"); +export const ProfileAddressRippleInterpreter = buildAddressInterpreter("xrp"); +export const ProfileAddressBitcoinCashInterpreter = + buildAddressInterpreter("bch"); +export const ProfileAddressBinanceInterpreter = buildAddressInterpreter("bnb"); + +export const ADDRESS_INTERPRETERS = { + ethereum: ProfileAddressEthereumInterpreter, + base: ProfileAddressBaseInterpreter, + bitcoin: ProfileAddressBitcoinInterpreter, + solana: ProfileAddressSolanaInterpreter, + litecoin: ProfileAddressLitecoinInterpreter, + dogecoin: ProfileAddressDogecoinInterpreter, + monacoin: ProfileAddressMonacoinInterpreter, + rootstock: ProfileAddressRootstockInterpreter, + ripple: ProfileAddressRippleInterpreter, + bitcoincash: ProfileAddressBitcoinCashInterpreter, + binance: ProfileAddressBinanceInterpreter, +} as const satisfies Record>; diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/images.test.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/images.test.ts similarity index 74% rename from apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/images.test.ts rename to apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/images.test.ts index e073b48982..e524c5a691 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/images.test.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/images.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { ProfileAvatarParser, ProfileHeaderParser } from "./images"; +import { ProfileAvatarInterpreter, ProfileHeaderInterpreter } from "./images"; import { profileRecordsModel } from "./test-helpers"; vi.mock("@/di", () => ({ @@ -11,9 +11,9 @@ vi.mock("@/di", () => ({ }, })); -describe("ProfileAvatarParser", () => { +describe("ProfileAvatarInterpreter", () => { it("has correct selection", () => { - expect(ProfileAvatarParser.selection).toEqual({ texts: ["avatar"] }); + expect(ProfileAvatarInterpreter.selection).toEqual({ texts: ["avatar"] }); }); it.each([ @@ -38,20 +38,20 @@ describe("ProfileAvatarParser", () => { { httpUrl: "https://metadata.ens.domains/sepolia/avatar/test.eth" }, ], ])("parses %s", (_message, texts, expected) => { - expect(ProfileAvatarParser.parse(profileRecordsModel(texts))).toEqual(expected); + expect(ProfileAvatarInterpreter.interpret(profileRecordsModel(texts))).toEqual(expected); }); it.each([ ["record unset", {}], ["empty string", { avatar: "" }], ])("returns null: %s", (_message, texts) => { - expect(ProfileAvatarParser.parse(profileRecordsModel(texts))).toBeNull(); + expect(ProfileAvatarInterpreter.interpret(profileRecordsModel(texts))).toBeNull(); }); }); -describe("ProfileHeaderParser", () => { +describe("ProfileHeaderInterpreter", () => { it("has correct selection", () => { - expect(ProfileHeaderParser.selection).toEqual({ texts: ["header"] }); + expect(ProfileHeaderInterpreter.selection).toEqual({ texts: ["header"] }); }); it.each([ @@ -76,13 +76,13 @@ describe("ProfileHeaderParser", () => { { httpUrl: "https://metadata.ens.domains/sepolia/header/test.eth" }, ], ])("parses %s", (_message, texts, expected) => { - expect(ProfileHeaderParser.parse(profileRecordsModel(texts))).toEqual(expected); + expect(ProfileHeaderInterpreter.interpret(profileRecordsModel(texts))).toEqual(expected); }); it.each([ ["record unset", {}], ["empty string", { header: "" }], ])("returns null: %s", (_message, texts) => { - expect(ProfileHeaderParser.parse(profileRecordsModel(texts))).toBeNull(); + expect(ProfileHeaderInterpreter.interpret(profileRecordsModel(texts))).toBeNull(); }); }); diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/images.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/images.ts similarity index 76% rename from apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/images.ts rename to apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/images.ts index 7f1b7b80d3..921de2978f 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/images.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/images.ts @@ -3,7 +3,7 @@ import { type EnsMetadataImageRecord, getEnsMetadataServiceImageUrl } from "enss import di from "@/di"; import type { ResolvedRecordsModel } from "@/omnigraph-api/lib/resolution/records-profile-model"; -import type { ProfileFieldParser } from "./types"; +import type { ProfileFieldInterpreter } from "./types"; export type ProfileImageResult = { httpUrl: string | null; @@ -22,11 +22,11 @@ function parseDirectImageHttpUrl(raw: string): string | null { } } -const buildImageParser = ( +const buildImageInterpreter = ( record: EnsMetadataImageRecord, -): ProfileFieldParser => ({ +): ProfileFieldInterpreter => ({ selection: { texts: [record] }, - parse: (result) => { + interpret: (result) => { const raw = result.records.texts?.[record]?.trim(); if (!raw) return null; @@ -37,10 +37,10 @@ const buildImageParser = ( }, }); -export const ProfileAvatarParser: ProfileFieldParser = - buildImageParser("avatar"); -export const ProfileHeaderParser: ProfileFieldParser = - buildImageParser("header"); +export const ProfileAvatarInterpreter: ProfileFieldInterpreter = + buildImageInterpreter("avatar"); +export const ProfileHeaderInterpreter: ProfileFieldInterpreter = + buildImageInterpreter("header"); /** * Derives an HTTP-compatible profile image URL from a resolved records model. diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/index.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/index.ts new file mode 100644 index 0000000000..7a3612b77b --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/index.ts @@ -0,0 +1,33 @@ +export { + ADDRESS_INTERPRETERS, + ProfileAddressBaseInterpreter, + ProfileAddressBinanceInterpreter, + ProfileAddressBitcoinCashInterpreter, + ProfileAddressBitcoinInterpreter, + ProfileAddressDogecoinInterpreter, + ProfileAddressEthereumInterpreter, + ProfileAddressLitecoinInterpreter, + ProfileAddressMonacoinInterpreter, + ProfileAddressRippleInterpreter, + ProfileAddressRootstockInterpreter, + ProfileAddressSolanaInterpreter, +} from "./addresses"; +export type { ProfileImageResult } from "./images"; +export { + ProfileAvatarInterpreter, + ProfileHeaderInterpreter, +} from "./images"; +export { + SOCIAL_INTERPRETERS, + SocialGithubInterpreter, + SocialKeybaseInterpreter, + SocialLinkedInInterpreter, + SocialTelegramInterpreter, + SocialTwitterInterpreter, +} from "./social"; +export { + ProfileDescriptionInterpreter, + ProfileEmailInterpreter, + ProfileWebsiteInterpreter, +} from "./texts"; +export type { ProfileFieldInterpreter } from "./types"; diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/social.test.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/social.test.ts similarity index 81% rename from apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/social.test.ts rename to apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/social.test.ts index f5d7e61e40..ccb5d40807 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/social.test.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/social.test.ts @@ -1,19 +1,19 @@ import { describe, expect, it } from "vitest"; import { - SocialGithubParser, - SocialKeybaseParser, - SocialLinkedInParser, - SocialTelegramParser, - SocialTwitterParser, + SocialGithubInterpreter, + SocialKeybaseInterpreter, + SocialLinkedInInterpreter, + SocialTelegramInterpreter, + SocialTwitterInterpreter, } from "./social"; import { profileRecordsModel } from "./test-helpers"; -import type { ProfileFieldParser } from "./types"; +import type { ProfileFieldInterpreter } from "./types"; type SocialResult = { handle: string; httpUrl: string }; /** - * Generates common test cases shared across social parsers: + * Generates common test cases shared across social interpreters: * bare handle, @-prefixed, leading/trailing whitespace. */ function commonParseCases( @@ -28,7 +28,7 @@ function commonParseCases( } /** - * Generates common null-result test cases shared across social parsers. + * Generates common null-result test cases shared across social interpreters. */ function commonNullCases(primaryKey: string): [label: string, texts: Record][] { return [ @@ -54,9 +54,9 @@ function urlVariantCases( ]; } -function describeSocialParser( +function describeSocialInterpreter( name: string, - parser: ProfileFieldParser, + interpreter: ProfileFieldInterpreter, primaryKey: string, { selection, @@ -70,15 +70,15 @@ function describeSocialParser( ) { describe(name, () => { it("has correct selection", () => { - expect(parser.selection).toEqual(selection); + expect(interpreter.selection).toEqual(selection); }); - it.each(parseCases)("parses %s", (_label, input, expected) => { - expect(parser.parse(profileRecordsModel({ [primaryKey]: input }))).toEqual(expected); + it.each(parseCases)("interprets %s", (_label, input, expected) => { + expect(interpreter.interpret(profileRecordsModel({ [primaryKey]: input }))).toEqual(expected); }); it.each([...commonNullCases(primaryKey), ...nullCases])("returns null: %s", (_label, texts) => { - expect(parser.parse(profileRecordsModel(texts))).toBeNull(); + expect(interpreter.interpret(profileRecordsModel(texts))).toBeNull(); }); }); } @@ -90,7 +90,7 @@ const EXPECTED_GITHUB: SocialResult = { httpUrl: "https://github.com/itslevchiks", }; -describeSocialParser("SocialGithubParser", SocialGithubParser, "com.github", { +describeSocialInterpreter("SocialGithubInterpreter", SocialGithubInterpreter, "com.github", { selection: { texts: ["com.github", "vnd.github"] }, parseCases: [ ...commonParseCases("itslevchiks", EXPECTED_GITHUB), @@ -143,7 +143,7 @@ const EXPECTED_TWITTER: SocialResult = { httpUrl: "https://x.com/itslevchiks", }; -describeSocialParser("SocialTwitterParser", SocialTwitterParser, "com.x", { +describeSocialInterpreter("SocialTwitterInterpreter", SocialTwitterInterpreter, "com.x", { selection: { texts: ["com.x", "com.twitter", "vnd.twitter"] }, parseCases: [ ...commonParseCases("itslevchiks", EXPECTED_TWITTER), @@ -171,7 +171,7 @@ const EXPECTED_TELEGRAM: SocialResult = { httpUrl: "https://t.me/itslevchiks", }; -describeSocialParser("SocialTelegramParser", SocialTelegramParser, "org.telegram", { +describeSocialInterpreter("SocialTelegramInterpreter", SocialTelegramInterpreter, "org.telegram", { selection: { texts: ["org.telegram"] }, parseCases: [ ...commonParseCases("itslevchiks", EXPECTED_TELEGRAM), @@ -194,7 +194,7 @@ const EXPECTED_LINKEDIN: SocialResult = { httpUrl: "https://www.linkedin.com/in/itslevchiks", }; -describeSocialParser("SocialLinkedInParser", SocialLinkedInParser, "com.linkedin", { +describeSocialInterpreter("SocialLinkedInInterpreter", SocialLinkedInInterpreter, "com.linkedin", { selection: { texts: ["com.linkedin"] }, parseCases: [ ...commonParseCases("itslevchiks", EXPECTED_LINKEDIN), @@ -220,7 +220,7 @@ const EXPECTED_KEYBASE: SocialResult = { httpUrl: "https://keybase.io/itslevchiks", }; -describeSocialParser("SocialKeybaseParser", SocialKeybaseParser, "io.keybase", { +describeSocialInterpreter("SocialKeybaseInterpreter", SocialKeybaseInterpreter, "io.keybase", { selection: { texts: ["io.keybase"] }, parseCases: [ ...commonParseCases("itslevchiks", EXPECTED_KEYBASE), @@ -234,16 +234,16 @@ describeSocialParser("SocialKeybaseParser", SocialKeybaseParser, "io.keybase", { // --- Fallback key tests --- -describe("SocialGithubParser (vnd.github fallback)", () => { +describe("SocialGithubInterpreter (vnd.github fallback)", () => { it("falls back to vnd.github when com.github is unset", () => { - expect(SocialGithubParser.parse(profileRecordsModel({ "vnd.github": "itslevchiks" }))).toEqual( - EXPECTED_GITHUB, - ); + expect( + SocialGithubInterpreter.interpret(profileRecordsModel({ "vnd.github": "itslevchiks" })), + ).toEqual(EXPECTED_GITHUB); }); it("prefers com.github over vnd.github", () => { expect( - SocialGithubParser.parse( + SocialGithubInterpreter.interpret( profileRecordsModel({ "com.github": "primary-user", "vnd.github": "legacy-user" }), ), ).toEqual({ handle: "primary-user", httpUrl: "https://github.com/primary-user" }); @@ -251,23 +251,23 @@ describe("SocialGithubParser (vnd.github fallback)", () => { it("falls back to vnd.github when com.github is empty", () => { expect( - SocialGithubParser.parse( + SocialGithubInterpreter.interpret( profileRecordsModel({ "com.github": "", "vnd.github": "legacy-user" }), ), ).toEqual({ handle: "legacy-user", httpUrl: "https://github.com/legacy-user" }); }); }); -describe("SocialTwitterParser (text key fallbacks)", () => { +describe("SocialTwitterInterpreter (text key fallbacks)", () => { it("falls back to com.twitter when com.x is unset", () => { expect( - SocialTwitterParser.parse(profileRecordsModel({ "com.twitter": "itslevchiks" })), + SocialTwitterInterpreter.interpret(profileRecordsModel({ "com.twitter": "itslevchiks" })), ).toEqual(EXPECTED_TWITTER); }); it("prefers com.x over com.twitter", () => { expect( - SocialTwitterParser.parse( + SocialTwitterInterpreter.interpret( profileRecordsModel({ "com.x": "primaryuser", "com.twitter": "legacyuser" }), ), ).toEqual({ handle: "primaryuser", httpUrl: "https://x.com/primaryuser" }); @@ -275,19 +275,21 @@ describe("SocialTwitterParser (text key fallbacks)", () => { it("falls back to com.twitter when com.x is empty", () => { expect( - SocialTwitterParser.parse(profileRecordsModel({ "com.x": "", "com.twitter": "legacyuser" })), + SocialTwitterInterpreter.interpret( + profileRecordsModel({ "com.x": "", "com.twitter": "legacyuser" }), + ), ).toEqual({ handle: "legacyuser", httpUrl: "https://x.com/legacyuser" }); }); it("falls back to vnd.twitter when com.x and com.twitter are unset", () => { expect( - SocialTwitterParser.parse(profileRecordsModel({ "vnd.twitter": "itslevchiks" })), + SocialTwitterInterpreter.interpret(profileRecordsModel({ "vnd.twitter": "itslevchiks" })), ).toEqual(EXPECTED_TWITTER); }); it("prefers com.twitter over vnd.twitter", () => { expect( - SocialTwitterParser.parse( + SocialTwitterInterpreter.interpret( profileRecordsModel({ "com.twitter": "primaryuser", "vnd.twitter": "legacyuser" }), ), ).toEqual({ handle: "primaryuser", httpUrl: "https://x.com/primaryuser" }); @@ -295,7 +297,7 @@ describe("SocialTwitterParser (text key fallbacks)", () => { it("falls back to vnd.twitter when com.x and com.twitter are empty", () => { expect( - SocialTwitterParser.parse( + SocialTwitterInterpreter.interpret( profileRecordsModel({ "com.x": "", "com.twitter": "", "vnd.twitter": "legacyuser" }), ), ).toEqual({ handle: "legacyuser", httpUrl: "https://x.com/legacyuser" }); diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/social.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/social.ts similarity index 64% rename from apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/social.ts rename to apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/social.ts index 5a3097f1cf..cb58aa0344 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/social.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/social.ts @@ -1,4 +1,4 @@ -import type { ProfileFieldParser } from "./types"; +import type { ProfileFieldInterpreter } from "./types"; export type SocialHandleResult = { handle: string; @@ -77,17 +77,17 @@ export function parseSocialHandle({ return { handle, httpUrl: httpUrl ?? `${baseUrl}/${handle}` }; } -const socialParser = ( +const socialInterpreter = ( textKeys: string | readonly string[], hostnames: readonly string[], baseUrl: string, handlePattern: RegExp, -): ProfileFieldParser => { +): ProfileFieldInterpreter => { const keys = typeof textKeys === "string" ? [textKeys] : [...textKeys]; const opts = { hostnames, baseUrl, handlePattern }; return { selection: { texts: keys }, - parse: (result) => { + interpret: (result) => { for (const key of keys) { const parsed = parseSocialHandle({ value: result.records.texts?.[key], ...opts }); if (parsed !== null) return parsed; @@ -97,46 +97,51 @@ const socialParser = ( }; }; -export const SocialGithubParser: ProfileFieldParser = socialParser( - ["com.github", "vnd.github"], - ["github.com", "www.github.com"], - "https://github.com", - /^[A-Za-z0-9_./-]+$/, -); - -export const SocialTwitterParser: ProfileFieldParser = socialParser( - ["com.x", "com.twitter", "vnd.twitter"], - ["twitter.com", "www.twitter.com", "x.com", "www.x.com"], - "https://x.com", - /^[A-Za-z0-9_]+$/, -); - -export const SocialTelegramParser: ProfileFieldParser = socialParser( - "org.telegram", - ["t.me", "telegram.me", "www.telegram.me", "www.t.me"], - "https://t.me", - /^[A-Za-z0-9_]+$/, -); - -export const SocialLinkedInParser: ProfileFieldParser = socialParser( - "com.linkedin", - ["linkedin.com", "www.linkedin.com"], - "https://www.linkedin.com/in", - /^[A-Za-z0-9_-]+$/, -); - -export const SocialKeybaseParser: ProfileFieldParser = socialParser( - "io.keybase", - ["keybase.io", "www.keybase.io"], - "https://keybase.io", - /^[A-Za-z0-9_]+$/, -); - -/** All social parsers keyed by their GraphQL field name. */ -export const SOCIAL_PARSERS = { - github: SocialGithubParser, - twitter: SocialTwitterParser, - telegram: SocialTelegramParser, - linkedin: SocialLinkedInParser, - keybase: SocialKeybaseParser, -} as const satisfies Record>; +export const SocialGithubInterpreter: ProfileFieldInterpreter = + socialInterpreter( + ["com.github", "vnd.github"], + ["github.com", "www.github.com"], + "https://github.com", + /^[A-Za-z0-9_./-]+$/, + ); + +export const SocialTwitterInterpreter: ProfileFieldInterpreter = + socialInterpreter( + ["com.x", "com.twitter", "vnd.twitter"], + ["twitter.com", "www.twitter.com", "x.com", "www.x.com"], + "https://x.com", + /^[A-Za-z0-9_]+$/, + ); + +export const SocialTelegramInterpreter: ProfileFieldInterpreter = + socialInterpreter( + "org.telegram", + ["t.me", "telegram.me", "www.telegram.me", "www.t.me"], + "https://t.me", + /^[A-Za-z0-9_]+$/, + ); + +export const SocialLinkedInInterpreter: ProfileFieldInterpreter = + socialInterpreter( + "com.linkedin", + ["linkedin.com", "www.linkedin.com"], + "https://www.linkedin.com/in", + /^[A-Za-z0-9_-]+$/, + ); + +export const SocialKeybaseInterpreter: ProfileFieldInterpreter = + socialInterpreter( + "io.keybase", + ["keybase.io", "www.keybase.io"], + "https://keybase.io", + /^[A-Za-z0-9_]+$/, + ); + +/** All social interpreters keyed by their GraphQL field name. */ +export const SOCIAL_INTERPRETERS = { + github: SocialGithubInterpreter, + twitter: SocialTwitterInterpreter, + telegram: SocialTelegramInterpreter, + linkedin: SocialLinkedInInterpreter, + keybase: SocialKeybaseInterpreter, +} as const satisfies Record>; diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/test-helpers.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/test-helpers.ts similarity index 100% rename from apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/test-helpers.ts rename to apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/test-helpers.ts diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/texts.test.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/texts.test.ts similarity index 58% rename from apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/texts.test.ts rename to apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/texts.test.ts index 0cdc446d6f..c108f3125b 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/texts.test.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/texts.test.ts @@ -1,38 +1,42 @@ import { describe, expect, it } from "vitest"; import { profileRecordsModel } from "./test-helpers"; -import { ProfileDescriptionParser, ProfileEmailParser, ProfileWebsiteParser } from "./texts"; +import { + ProfileDescriptionInterpreter, + ProfileEmailInterpreter, + ProfileWebsiteInterpreter, +} from "./texts"; -describe("ProfileDescriptionParser", () => { +describe("ProfileDescriptionInterpreter", () => { it("has correct selection", () => { - expect(ProfileDescriptionParser.selection).toEqual({ texts: ["description"] }); + expect(ProfileDescriptionInterpreter.selection).toEqual({ texts: ["description"] }); }); it.each([ ["plain text", { description: "Hello" }, "Hello"], ["whitespace preserved", { description: " Hello " }, " Hello "], ])("parses %s", (_message, texts, expected) => { - expect(ProfileDescriptionParser.parse(profileRecordsModel(texts))).toBe(expected); + expect(ProfileDescriptionInterpreter.interpret(profileRecordsModel(texts))).toBe(expected); }); it.each([ ["record unset", {}], ["empty string", { description: "" }], ])("returns null: %s", (_message, texts) => { - expect(ProfileDescriptionParser.parse(profileRecordsModel(texts))).toBeNull(); + expect(ProfileDescriptionInterpreter.interpret(profileRecordsModel(texts))).toBeNull(); }); }); -describe("ProfileWebsiteParser", () => { +describe("ProfileWebsiteInterpreter", () => { it("has correct selection", () => { - expect(ProfileWebsiteParser.selection).toEqual({ texts: ["url"] }); + expect(ProfileWebsiteInterpreter.selection).toEqual({ texts: ["url"] }); }); it.each([ ["https URL", { url: "https://example.com" }, "https://example.com"], ["http URL", { url: "http://example.com" }, "http://example.com"], ])("parses %s", (_message, texts, expected) => { - expect(ProfileWebsiteParser.parse(profileRecordsModel(texts))).toBe(expected); + expect(ProfileWebsiteInterpreter.interpret(profileRecordsModel(texts))).toBe(expected); }); it.each([ @@ -42,26 +46,26 @@ describe("ProfileWebsiteParser", () => { ["non-http scheme", { url: "ipfs://example.com" }], ["not a URL", { url: "not-a-url" }], ])("returns null: %s", (_message, texts) => { - expect(ProfileWebsiteParser.parse(profileRecordsModel(texts))).toBeNull(); + expect(ProfileWebsiteInterpreter.interpret(profileRecordsModel(texts))).toBeNull(); }); it("trims surrounding whitespace before parsing", () => { expect( - ProfileWebsiteParser.parse(profileRecordsModel({ url: " https://example.com " })), + ProfileWebsiteInterpreter.interpret(profileRecordsModel({ url: " https://example.com " })), ).toBe("https://example.com"); }); }); -describe("ProfileEmailParser", () => { +describe("ProfileEmailInterpreter", () => { it("has correct selection", () => { - expect(ProfileEmailParser.selection).toEqual({ texts: ["email"] }); + expect(ProfileEmailInterpreter.selection).toEqual({ texts: ["email"] }); }); it.each([ ["plain email", { email: "user@example.com" }, "user@example.com"], ["email with dots", { email: "first.last@example.org" }, "first.last@example.org"], ])("parses %s", (_message, texts, expected) => { - expect(ProfileEmailParser.parse(profileRecordsModel(texts))).toBe(expected); + expect(ProfileEmailInterpreter.interpret(profileRecordsModel(texts))).toBe(expected); }); it.each([ @@ -72,12 +76,12 @@ describe("ProfileEmailParser", () => { ["missing domain", { email: "user@" }], ["spaces inside", { email: "user @example.com" }], ])("returns null: %s", (_message, texts) => { - expect(ProfileEmailParser.parse(profileRecordsModel(texts))).toBeNull(); + expect(ProfileEmailInterpreter.interpret(profileRecordsModel(texts))).toBeNull(); }); it("trims surrounding whitespace from valid email", () => { - expect(ProfileEmailParser.parse(profileRecordsModel({ email: " user@example.com " }))).toBe( - "user@example.com", - ); + expect( + ProfileEmailInterpreter.interpret(profileRecordsModel({ email: " user@example.com " })), + ).toBe("user@example.com"); }); }); diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/texts.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/texts.ts similarity index 60% rename from apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/texts.ts rename to apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/texts.ts index 2061d3003f..ec3c027e97 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/texts.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/texts.ts @@ -2,24 +2,25 @@ import type { Email } from "enssdk"; import { makeEmailSchema } from "@ensnode/ensnode-sdk/internal"; -import type { ProfileFieldParser } from "./types"; +import type { ProfileFieldInterpreter } from "./types"; const profileEmailSchema = makeEmailSchema("email text record"); -const textParser = (key: string): ProfileFieldParser => ({ +const textInterpreter = (key: string): ProfileFieldInterpreter => ({ selection: { texts: [key] }, - parse: (result) => { + interpret: (result) => { const raw = result.records.texts?.[key]; if (raw == null || raw === "") return null; return raw; }, }); -export const ProfileDescriptionParser: ProfileFieldParser = textParser("description"); +export const ProfileDescriptionInterpreter: ProfileFieldInterpreter = + textInterpreter("description"); -export const ProfileEmailParser: ProfileFieldParser = { +export const ProfileEmailInterpreter: ProfileFieldInterpreter = { selection: { texts: ["email"] }, - parse: (result) => { + interpret: (result) => { const raw = result.records.texts?.email?.trim(); if (!raw) return null; const parsed = profileEmailSchema.safeParse(raw); @@ -27,9 +28,9 @@ export const ProfileEmailParser: ProfileFieldParser = { }, }; -const urlParser = (key: string): ProfileFieldParser => ({ +const urlInterpreter = (key: string): ProfileFieldInterpreter => ({ selection: { texts: [key] }, - parse: (result) => { + interpret: (result) => { const trimmed = result.records.texts?.[key]?.trim(); if (!trimmed) return null; if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) { @@ -45,4 +46,4 @@ const urlParser = (key: string): ProfileFieldParser => ({ }, }); -export const ProfileWebsiteParser: ProfileFieldParser = urlParser("url"); +export const ProfileWebsiteInterpreter: ProfileFieldInterpreter = urlInterpreter("url"); diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/types.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/types.ts similarity index 57% rename from apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/types.ts rename to apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/types.ts index 85d0886cf7..75b793af1c 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/types.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/types.ts @@ -5,12 +5,12 @@ import type { ResolvedRecordsModel } from "@/omnigraph-api/lib/resolution/record /** * Declares which records a profile field needs and how to derive its GraphQL output from them. * - * Each profile field is implemented as a singleton `ProfileFieldParser`. The parent resolver - * passes the shared `ResolvedRecordsModel` to `parse`, keeping all resolution in one round-trip. + * Each profile field is implemented as a singleton `ProfileFieldInterpreter`. The parent resolver + * passes the shared `ResolvedRecordsModel` to `interpret`, keeping all resolution in one round-trip. */ -export interface ProfileFieldParser { - /** The record keys this parser requires. Merged into the parent selection before resolution. */ +export interface ProfileFieldInterpreter { + /** The record keys this interpreter requires. Merged into the parent selection before resolution. */ selection: ResolverRecordsSelection; /** Derive the GraphQL output from the resolved records, or null if the record is unset. */ - parse(result: ResolvedRecordsModel): TOutput | null; + interpret(result: ResolvedRecordsModel): TOutput | null; } diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/addresses.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/addresses.ts deleted file mode 100644 index c94b1cd673..0000000000 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/addresses.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { type CoinName, getCoderByCoinName } from "@ensdomains/address-encoder"; -import { - type BinanceAddress, - type BitcoinAddress, - type BitcoinCashAddress, - type CoinType, - type DogecoinAddress, - type LitecoinAddress, - type MonacoinAddress, - type RippleAddress, - type RootstockAddress, - type SolanaAddress, - toNormalizedAddress, -} from "enssdk"; -import { isHex, toBytes } from "viem"; - -import type { ProfileFieldParser } from "./types"; - -const buildAddressParser = ( - coinNameOrType: CoinName, - format?: (encoded: string) => T, -): ProfileFieldParser => { - const coder = getCoderByCoinName(coinNameOrType); - const coinType = coder.coinType as CoinType; - - return { - selection: { addresses: [coinType] }, - parse: (result) => { - const raw = result.records.addresses?.[coinType]; - if (raw == null || raw === "0x") return null; - if (!isHex(raw)) return null; - - try { - const bytes = toBytes(raw); - if (bytes.length === 0 || bytes.every((byte) => byte === 0)) return null; - - const encoded = coder.encode(bytes); - - if (format) { - return format(encoded); - } - - return encoded as T; - } catch { - return null; - } - }, - }; -}; - -export const ProfileAddressEthereumParser = buildAddressParser("eth", toNormalizedAddress); -export const ProfileAddressBaseParser = buildAddressParser("base", toNormalizedAddress); -export const ProfileAddressBitcoinParser = buildAddressParser("btc"); -export const ProfileAddressSolanaParser = buildAddressParser("sol"); -export const ProfileAddressLitecoinParser = buildAddressParser("ltc"); -export const ProfileAddressDogecoinParser = buildAddressParser("doge"); -export const ProfileAddressMonacoinParser = buildAddressParser("mona"); - -export const ProfileAddressRootstockParser = buildAddressParser("rbtc"); -export const ProfileAddressRippleParser = buildAddressParser("xrp"); -export const ProfileAddressBitcoinCashParser = buildAddressParser("bch"); -export const ProfileAddressBinanceParser = buildAddressParser("bnb"); - -export const ADDRESS_PARSERS = { - ethereum: ProfileAddressEthereumParser, - base: ProfileAddressBaseParser, - bitcoin: ProfileAddressBitcoinParser, - solana: ProfileAddressSolanaParser, - litecoin: ProfileAddressLitecoinParser, - dogecoin: ProfileAddressDogecoinParser, - monacoin: ProfileAddressMonacoinParser, - rootstock: ProfileAddressRootstockParser, - ripple: ProfileAddressRippleParser, - bitcoincash: ProfileAddressBitcoinCashParser, - binance: ProfileAddressBinanceParser, -} as const satisfies Record>; diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/index.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/index.ts deleted file mode 100644 index c3243f96ee..0000000000 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/parsers/index.ts +++ /dev/null @@ -1,29 +0,0 @@ -export { - ADDRESS_PARSERS, - ProfileAddressBaseParser, - ProfileAddressBinanceParser, - ProfileAddressBitcoinCashParser, - ProfileAddressBitcoinParser, - ProfileAddressDogecoinParser, - ProfileAddressEthereumParser, - ProfileAddressLitecoinParser, - ProfileAddressMonacoinParser, - ProfileAddressRippleParser, - ProfileAddressRootstockParser, - ProfileAddressSolanaParser, -} from "./addresses"; -export type { ProfileImageResult } from "./images"; -export { - ProfileAvatarParser, - ProfileHeaderParser, -} from "./images"; -export { - SOCIAL_PARSERS, - SocialGithubParser, - SocialKeybaseParser, - SocialLinkedInParser, - SocialTelegramParser, - SocialTwitterParser, -} from "./social"; -export { ProfileDescriptionParser, ProfileEmailParser, ProfileWebsiteParser } from "./texts"; -export type { ProfileFieldParser } from "./types"; diff --git a/apps/ensapi/src/omnigraph-api/schema/profile.ts b/apps/ensapi/src/omnigraph-api/schema/profile.ts index bd747778b5..fcfb409fa8 100644 --- a/apps/ensapi/src/omnigraph-api/schema/profile.ts +++ b/apps/ensapi/src/omnigraph-api/schema/profile.ts @@ -1,13 +1,13 @@ import { builder } from "@/omnigraph-api/builder"; import { - ADDRESS_PARSERS, - ProfileAvatarParser, - ProfileDescriptionParser, - ProfileEmailParser, - ProfileHeaderParser, - ProfileWebsiteParser, - SOCIAL_PARSERS, -} from "@/omnigraph-api/lib/resolution/profile/parsers"; + ADDRESS_INTERPRETERS, + ProfileAvatarInterpreter, + ProfileDescriptionInterpreter, + ProfileEmailInterpreter, + ProfileHeaderInterpreter, + ProfileWebsiteInterpreter, + SOCIAL_INTERPRETERS, +} from "@/omnigraph-api/lib/resolution/profile/interpreters"; import { profileAddressesContainerDescription, profileAddressFieldDescription, @@ -48,31 +48,31 @@ ProfileSocialsRef.implement({ description: profileSocialFieldDescription("GitHub"), type: ProfileSocialAccountRef, nullable: true, - resolve: (model) => SOCIAL_PARSERS.github.parse(model), + resolve: (model) => SOCIAL_INTERPRETERS.github.interpret(model), }), telegram: t.field({ description: profileSocialFieldDescription("Telegram"), type: ProfileSocialAccountRef, nullable: true, - resolve: (model) => SOCIAL_PARSERS.telegram.parse(model), + resolve: (model) => SOCIAL_INTERPRETERS.telegram.interpret(model), }), twitter: t.field({ description: profileSocialFieldDescription("X (Twitter)"), type: ProfileSocialAccountRef, nullable: true, - resolve: (model) => SOCIAL_PARSERS.twitter.parse(model), + resolve: (model) => SOCIAL_INTERPRETERS.twitter.interpret(model), }), linkedin: t.field({ description: profileSocialFieldDescription("LinkedIn"), type: ProfileSocialAccountRef, nullable: true, - resolve: (model) => SOCIAL_PARSERS.linkedin.parse(model), + resolve: (model) => SOCIAL_INTERPRETERS.linkedin.interpret(model), }), keybase: t.field({ description: profileSocialFieldDescription("Keybase"), type: ProfileSocialAccountRef, nullable: true, - resolve: (model) => SOCIAL_PARSERS.keybase.parse(model), + resolve: (model) => SOCIAL_INTERPRETERS.keybase.interpret(model), }), }), }); @@ -86,67 +86,67 @@ ProfileAddressesRef.implement({ description: profileAddressFieldDescription("Ethereum"), type: "Address", nullable: true, - resolve: (model) => ADDRESS_PARSERS.ethereum.parse(model), + resolve: (model) => ADDRESS_INTERPRETERS.ethereum.interpret(model), }), base: t.field({ description: profileAddressFieldDescription("Base"), type: "Address", nullable: true, - resolve: (model) => ADDRESS_PARSERS.base.parse(model), + resolve: (model) => ADDRESS_INTERPRETERS.base.interpret(model), }), bitcoin: t.field({ description: profileAddressFieldDescription("Bitcoin"), type: "BitcoinAddress", nullable: true, - resolve: (model) => ADDRESS_PARSERS.bitcoin.parse(model), + resolve: (model) => ADDRESS_INTERPRETERS.bitcoin.interpret(model), }), solana: t.field({ description: profileAddressFieldDescription("Solana"), type: "SolanaAddress", nullable: true, - resolve: (model) => ADDRESS_PARSERS.solana.parse(model), + resolve: (model) => ADDRESS_INTERPRETERS.solana.interpret(model), }), litecoin: t.field({ description: profileAddressFieldDescription("Litecoin"), type: "LitecoinAddress", nullable: true, - resolve: (model) => ADDRESS_PARSERS.litecoin.parse(model), + resolve: (model) => ADDRESS_INTERPRETERS.litecoin.interpret(model), }), dogecoin: t.field({ description: profileAddressFieldDescription("Dogecoin"), type: "DogecoinAddress", nullable: true, - resolve: (model) => ADDRESS_PARSERS.dogecoin.parse(model), + resolve: (model) => ADDRESS_INTERPRETERS.dogecoin.interpret(model), }), monacoin: t.field({ description: profileAddressFieldDescription("Monacoin"), type: "MonacoinAddress", nullable: true, - resolve: (model) => ADDRESS_PARSERS.monacoin.parse(model), + resolve: (model) => ADDRESS_INTERPRETERS.monacoin.interpret(model), }), rootstock: t.field({ description: profileAddressFieldDescription("Rootstock (RBTC)"), type: "RootstockAddress", nullable: true, - resolve: (model) => ADDRESS_PARSERS.rootstock.parse(model), + resolve: (model) => ADDRESS_INTERPRETERS.rootstock.interpret(model), }), ripple: t.field({ description: profileAddressFieldDescription("Ripple (XRP)"), type: "RippleAddress", nullable: true, - resolve: (model) => ADDRESS_PARSERS.ripple.parse(model), + resolve: (model) => ADDRESS_INTERPRETERS.ripple.interpret(model), }), bitcoincash: t.field({ description: profileAddressFieldDescription("Bitcoin Cash"), type: "BitcoinCashAddress", nullable: true, - resolve: (model) => ADDRESS_PARSERS.bitcoincash.parse(model), + resolve: (model) => ADDRESS_INTERPRETERS.bitcoincash.interpret(model), }), binance: t.field({ description: profileAddressFieldDescription("Binance Chain (BNB)"), type: "BinanceAddress", nullable: true, - resolve: (model) => ADDRESS_PARSERS.binance.parse(model), + resolve: (model) => ADDRESS_INTERPRETERS.binance.interpret(model), }), }), }); @@ -184,7 +184,7 @@ ProfileWebsiteRef.implement({ description: "The HTTP-compatible website URL. Returns null when the raw url record is unset, empty, not an http(s) URL, or cannot be parsed as a valid URL.", nullable: true, - resolve: (model) => ProfileWebsiteParser.parse(model), + resolve: (model) => ProfileWebsiteInterpreter.interpret(model), }), }), }); @@ -200,33 +200,33 @@ DomainProfileRef.implement({ "Interpreted avatar metadata. Returns null when the raw avatar record is unset or empty.", type: ProfileAvatarRef, nullable: true, - resolve: (model) => ProfileAvatarParser.parse(model), + resolve: (model) => ProfileAvatarInterpreter.interpret(model), }), header: t.field({ description: "Interpreted header metadata. Returns null when the raw header record is unset or empty.", type: ProfileHeaderRef, nullable: true, - resolve: (model) => ProfileHeaderParser.parse(model), + resolve: (model) => ProfileHeaderInterpreter.interpret(model), }), website: t.field({ description: profileWebsiteFieldDescription, type: ProfileWebsiteRef, nullable: true, - resolve: (model) => (ProfileWebsiteParser.parse(model) ? model : null), + resolve: (model) => (ProfileWebsiteInterpreter.interpret(model) ? model : null), }), description: t.string({ description: "The profile description. Returns null when the raw record is unset or empty. Non-empty values are returned as-is without format validation.", nullable: true, - resolve: (model) => ProfileDescriptionParser.parse(model), + resolve: (model) => ProfileDescriptionInterpreter.interpret(model), }), email: t.field({ description: "The contact email address. Returns null when the raw record is unset, empty, or fails email validation.", type: "Email", nullable: true, - resolve: (model) => ProfileEmailParser.parse(model), + resolve: (model) => ProfileEmailInterpreter.interpret(model), }), addresses: t.field({ description: profileAddressesContainerDescription, From 720b1f3260ead75d704237a3eafa263bc3dfa883 Mon Sep 17 00:00:00 2001 From: sevenzing Date: Wed, 3 Jun 2026 14:20:43 +0300 Subject: [PATCH 17/19] small rename --- .../lib/resolution/profile/interpreters/texts.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/texts.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/texts.ts index ec3c027e97..40618e8b3e 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/texts.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/texts.ts @@ -28,7 +28,7 @@ export const ProfileEmailInterpreter: ProfileFieldInterpreter = { }, }; -const urlInterpreter = (key: string): ProfileFieldInterpreter => ({ +const httpUrlInterpreter = (key: string): ProfileFieldInterpreter => ({ selection: { texts: [key] }, interpret: (result) => { const trimmed = result.records.texts?.[key]?.trim(); @@ -46,4 +46,4 @@ const urlInterpreter = (key: string): ProfileFieldInterpreter => ({ }, }); -export const ProfileWebsiteInterpreter: ProfileFieldInterpreter = urlInterpreter("url"); +export const ProfileWebsiteInterpreter: ProfileFieldInterpreter = httpUrlInterpreter("url"); From 606f5a97d7cac50158499fada4fcf3d947d52004 Mon Sep 17 00:00:00 2001 From: sevenzing Date: Wed, 3 Jun 2026 14:20:56 +0300 Subject: [PATCH 18/19] remove useless function --- .../src/lib/ens-metadata-service.test.ts | 20 +++++++------------ .../enssdk/src/lib/ens-metadata-service.ts | 15 +++----------- .../src/components/identity/EnsAvatar.tsx | 4 ++-- packages/namehash-ui/src/index.ts | 2 -- 4 files changed, 12 insertions(+), 29 deletions(-) diff --git a/packages/enssdk/src/lib/ens-metadata-service.test.ts b/packages/enssdk/src/lib/ens-metadata-service.test.ts index 7ca3a0a860..df85b415d3 100644 --- a/packages/enssdk/src/lib/ens-metadata-service.test.ts +++ b/packages/enssdk/src/lib/ens-metadata-service.test.ts @@ -1,14 +1,17 @@ import { asInterpretedName, type Name } from "enssdk"; import { describe, expect, it } from "vitest"; -import { - getEnsMetadataServiceAvatarUrl, - getEnsMetadataServiceImageUrl, -} from "./ens-metadata-service"; +import { getEnsMetadataServiceImageUrl } from "./ens-metadata-service"; describe("getEnsMetadataServiceImageUrl", () => { const name = asInterpretedName("vitalik.eth"); + it("returns a mainnet avatar URL", () => { + expect( + getEnsMetadataServiceImageUrl(asInterpretedName("test.eth"), "mainnet", "avatar")?.href, + ).toBe("https://metadata.ens.domains/mainnet/avatar/test.eth"); + }); + it("returns a sepolia header URL", () => { expect(getEnsMetadataServiceImageUrl(name, "sepolia", "header")?.href).toBe( "https://metadata.ens.domains/sepolia/header/vitalik.eth", @@ -29,12 +32,3 @@ describe("getEnsMetadataServiceImageUrl", () => { expect(getEnsMetadataServiceImageUrl(maliciousName as Name, "mainnet", "avatar")).toBeNull(); }); }); - -describe("getEnsMetadataServiceAvatarUrl", () => { - it("delegates to the avatar image endpoint", () => { - const name = asInterpretedName("test.eth"); - expect(getEnsMetadataServiceAvatarUrl(name, "mainnet")?.href).toBe( - "https://metadata.ens.domains/mainnet/avatar/test.eth", - ); - }); -}); diff --git a/packages/enssdk/src/lib/ens-metadata-service.ts b/packages/enssdk/src/lib/ens-metadata-service.ts index c85366fd50..e4dfd37210 100644 --- a/packages/enssdk/src/lib/ens-metadata-service.ts +++ b/packages/enssdk/src/lib/ens-metadata-service.ts @@ -36,6 +36,9 @@ export function getEnsMetadataServiceImageUrl( namespaceId: string, record: EnsMetadataImageRecord, ): URL | null { + // `new URL(name, base)` resolves absolute and protocol-relative `name` values against their + // own authority, not `base`. Reject URI-like inputs so only a name path segment is appended + // under `https://metadata.ens.domains/{network}/{record}/`. if (name.startsWith("//") || URI_SCHEME_PATTERN.test(name)) return null; const network = namespaceIdToMetadataNetwork(namespaceId); @@ -43,15 +46,3 @@ export function getEnsMetadataServiceImageUrl( return new URL(name, `https://metadata.ens.domains/${network}/${record}/`); } - -/** - * Build the avatar image URL for a name on the given ENS namespace that (once fetched) would - * load the avatar image for the given name from the ENS Metadata Service - * (https://metadata.ens.domains/docs). - * - * The returned URL is dynamically built based on the provided ENS namespace. Not all ENS - * namespaces are supported by the ENS Metadata Service. Therefore, the returned URL may be null. - */ -export function getEnsMetadataServiceAvatarUrl(name: Name, namespaceId: string): URL | null { - return getEnsMetadataServiceImageUrl(name, namespaceId, "avatar"); -} diff --git a/packages/namehash-ui/src/components/identity/EnsAvatar.tsx b/packages/namehash-ui/src/components/identity/EnsAvatar.tsx index 59a6341d3a..9f11c97c4f 100644 --- a/packages/namehash-ui/src/components/identity/EnsAvatar.tsx +++ b/packages/namehash-ui/src/components/identity/EnsAvatar.tsx @@ -1,6 +1,6 @@ import BoringAvatar from "boring-avatars"; import type { Name } from "enssdk"; -import { getEnsMetadataServiceAvatarUrl } from "enssdk"; +import { getEnsMetadataServiceImageUrl } from "enssdk"; import * as React from "react"; import type { ENSNamespaceId } from "@ensnode/datasources"; @@ -21,7 +21,7 @@ type ImageLoadingStatus = Parameters< export const EnsAvatar = ({ name, namespaceId, className, isSquare = false }: EnsAvatarProps) => { const [loadingStatus, setLoadingStatus] = React.useState("idle"); - const avatarUrl = getEnsMetadataServiceAvatarUrl(name, namespaceId); + const avatarUrl = getEnsMetadataServiceImageUrl(name, namespaceId, "avatar"); if (avatarUrl === null) { return ( diff --git a/packages/namehash-ui/src/index.ts b/packages/namehash-ui/src/index.ts index c72f4e3df7..353cfab479 100644 --- a/packages/namehash-ui/src/index.ts +++ b/packages/namehash-ui/src/index.ts @@ -1,7 +1,5 @@ import "./styles.css"; -export { getEnsMetadataServiceAvatarUrl } from "enssdk"; - export * from "./components/chains/ChainIcon"; export * from "./components/chains/ChainName"; export * from "./components/datetime/AbsoluteTime"; From 564e8e0d9c65c682a3bc77e087322033b677b51a Mon Sep 17 00:00:00 2001 From: sevenzing Date: Wed, 3 Jun 2026 14:24:47 +0300 Subject: [PATCH 19/19] simple renaming --- .../profile/profile-descriptions.ts | 6 +-- .../omnigraph-api/schema/forward-resolve.ts | 2 +- .../src/omnigraph-api/schema/profile.ts | 19 ++++---- .../src/omnigraph/generated/schema.graphql | 46 ++++++------------- 4 files changed, 27 insertions(+), 46 deletions(-) diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/profile-descriptions.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/profile-descriptions.ts index 6f928e82dc..e6590ba2e8 100644 --- a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/profile-descriptions.ts +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/profile-descriptions.ts @@ -11,7 +11,7 @@ export const profileSocialFieldDescription = (platform: string) => )}`; export const profileWebsiteFieldDescription = - "Interpreted website metadata. Returns null when the raw url record is unset, empty, or cannot be parsed as a valid http(s) URL."; + "The interpreted website on the profile of an ENS name."; export const profileImageHttpUrlFieldDescription = (recordLabel: "avatar" | "header") => `HTTP-compatible URL for fetching the ${recordLabel} image in web browsers. Abstraction over the raw ${recordLabel} record (IPFS, CAIP NFT references, etc.). ${nullWhenUninterpretable( @@ -19,7 +19,7 @@ export const profileImageHttpUrlFieldDescription = (recordLabel: "avatar" | "hea )} See https://docs.ens.domains/ensip/12.`; export const profileAddressesContainerDescription = - "Interpreted multicoin address records on a Name profile. Each field returns null independently when its raw record cannot be interpreted."; + "The interpreted addresses on the profile of an ENS name."; export const profileSocialsContainerDescription = - "Interpreted social accounts on a Name profile. Each field returns null independently when its raw record cannot be interpreted."; + "The interpreted social accounts on the profile of an ENS name."; diff --git a/apps/ensapi/src/omnigraph-api/schema/forward-resolve.ts b/apps/ensapi/src/omnigraph-api/schema/forward-resolve.ts index 609a13c41f..feed1204d8 100644 --- a/apps/ensapi/src/omnigraph-api/schema/forward-resolve.ts +++ b/apps/ensapi/src/omnigraph-api/schema/forward-resolve.ts @@ -46,7 +46,7 @@ ForwardResolveRef.implement({ }), profile: t.field({ description: - "An interpreted profile for this name. Returns null when the name is not resolvable (non-canonical, unnormalized, or no profile records were selected).", + "The interpreted profile of an ENS name. Returns null when the name is not resolvable (non-canonical, unnormalized, or no profile records were selected).", type: DomainProfileRef, nullable: true, resolve: (parent) => parent.result, diff --git a/apps/ensapi/src/omnigraph-api/schema/profile.ts b/apps/ensapi/src/omnigraph-api/schema/profile.ts index fcfb409fa8..314984b0af 100644 --- a/apps/ensapi/src/omnigraph-api/schema/profile.ts +++ b/apps/ensapi/src/omnigraph-api/schema/profile.ts @@ -25,15 +25,14 @@ export const ProfileSocialAccountRef = builder.objectRef("ProfileSocialAccount"); ProfileSocialAccountRef.implement({ - description: - "An interpreted social account. Only returned when the raw record was successfully parsed; otherwise the parent social field is null.", + description: "An interpreted social account on the profile of an ENS name.", fields: (t) => ({ handle: t.exposeString("handle", { - description: "The normalized social handle extracted from the raw record.", + description: "The handle of the social account.", nullable: false, }), httpUrl: t.exposeString("httpUrl", { - description: "The canonical HTTP-compatible social profile URL.", + description: "The HTTP-compatible url to the social account.", nullable: false, }), }), @@ -154,7 +153,7 @@ ProfileAddressesRef.implement({ export const ProfileAvatarRef = builder.objectRef("ProfileAvatar"); ProfileAvatarRef.implement({ - description: "Interpreted avatar metadata on a Name profile.", + description: "The interpreted avatar image on the profile of an ENS name.", fields: (t) => ({ httpUrl: t.exposeString("httpUrl", { description: profileImageHttpUrlFieldDescription("avatar"), @@ -166,7 +165,7 @@ ProfileAvatarRef.implement({ export const ProfileHeaderRef = builder.objectRef("ProfileHeader"); ProfileHeaderRef.implement({ - description: "Interpreted header metadata on a Name profile.", + description: "The interpreted header image on the profile of an ENS name.", fields: (t) => ({ httpUrl: t.exposeString("httpUrl", { description: profileImageHttpUrlFieldDescription("header"), @@ -192,8 +191,7 @@ ProfileWebsiteRef.implement({ export const DomainProfileRef = builder.objectRef("DomainProfile"); DomainProfileRef.implement({ - description: - "An interpreted profile for a name. Individual fields return null when their raw record is unset or cannot be interpreted; see each field's description for validation rules.", + description: "The interpreted profile of an ENS name.", fields: (t) => ({ avatar: t.field({ description: @@ -216,14 +214,13 @@ DomainProfileRef.implement({ resolve: (model) => (ProfileWebsiteInterpreter.interpret(model) ? model : null), }), description: t.string({ - description: - "The profile description. Returns null when the raw record is unset or empty. Non-empty values are returned as-is without format validation.", + description: "The interpreted description on the profile of an ENS name, or null when unset.", nullable: true, resolve: (model) => ProfileDescriptionInterpreter.interpret(model), }), email: t.field({ description: - "The contact email address. Returns null when the raw record is unset, empty, or fails email validation.", + "The interpreted email address on the profile of an ENS name, or null when unset or invalid.", type: "Email", nullable: true, resolve: (model) => ProfileEmailInterpreter.interpret(model), diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql index d4a3bca44e..f94400f849 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -413,13 +413,9 @@ input DomainPermissionsWhereInput { user: DomainPermissionsUserFilter } -""" -An interpreted profile for a name. Individual fields return null when their raw record is unset or cannot be interpreted; see each field's description for validation rules. -""" +"""The interpreted profile of an ENS name.""" type DomainProfile { - """ - Interpreted multicoin address records on a Name profile. Each field returns null independently when its raw record cannot be interpreted. - """ + """The interpreted addresses on the profile of an ENS name.""" addresses: ProfileAddresses """ @@ -428,12 +424,12 @@ type DomainProfile { avatar: ProfileAvatar """ - The profile description. Returns null when the raw record is unset or empty. Non-empty values are returned as-is without format validation. + The interpreted description on the profile of an ENS name, or null when unset. """ description: String """ - The contact email address. Returns null when the raw record is unset, empty, or fails email validation. + The interpreted email address on the profile of an ENS name, or null when unset or invalid. """ email: Email @@ -442,14 +438,10 @@ type DomainProfile { """ header: ProfileHeader - """ - Interpreted social accounts on a Name profile. Each field returns null independently when its raw record cannot be interpreted. - """ + """The interpreted social accounts on the profile of an ENS name.""" socials: ProfileSocials - """ - Interpreted website metadata. Returns null when the raw url record is unset, empty, or cannot be parsed as a valid http(s) URL. - """ + """The interpreted website on the profile of an ENS name.""" website: ProfileWebsite } @@ -988,7 +980,7 @@ type ForwardResolve { acceleration: AccelerationStatus! """ - An interpreted profile for this name. Returns null when the name is not resolvable (non-canonical, unnormalized, or no profile records were selected). + The interpreted profile of an ENS name. Returns null when the name is not resolvable (non-canonical, unnormalized, or no profile records were selected). """ profile: DomainProfile @@ -1281,9 +1273,7 @@ input PrimaryNamesWhereInput @oneOf { coinTypes: [CoinType!] } -""" -Interpreted multicoin address records on a Name profile. Each field returns null independently when its raw record cannot be interpreted. -""" +"""The interpreted addresses on the profile of an ENS name.""" type ProfileAddresses { """ The interpreted Base address. Returns null when the raw address record is unset, empty (`0x`), all-zero, not valid hex, or cannot be decoded and encoded for this coin type per ENSIP-9. @@ -1341,7 +1331,7 @@ type ProfileAddresses { solana: SolanaAddress } -"""Interpreted avatar metadata on a Name profile.""" +"""The interpreted avatar image on the profile of an ENS name.""" type ProfileAvatar { """ HTTP-compatible URL for fetching the avatar image in web browsers. Abstraction over the raw avatar record (IPFS, CAIP NFT references, etc.). Returns null when the raw value is not a direct http(s) URL and no fallback URL can be derived (including when the ENS Metadata Service is unavailable for this namespace). See https://docs.ens.domains/ensip/12. @@ -1349,7 +1339,7 @@ type ProfileAvatar { httpUrl: String } -"""Interpreted header metadata on a Name profile.""" +"""The interpreted header image on the profile of an ENS name.""" type ProfileHeader { """ HTTP-compatible URL for fetching the header image in web browsers. Abstraction over the raw header record (IPFS, CAIP NFT references, etc.). Returns null when the raw value is not a direct http(s) URL and no fallback URL can be derived (including when the ENS Metadata Service is unavailable for this namespace). See https://docs.ens.domains/ensip/12. @@ -1357,20 +1347,16 @@ type ProfileHeader { httpUrl: String } -""" -An interpreted social account. Only returned when the raw record was successfully parsed; otherwise the parent social field is null. -""" +"""An interpreted social account on the profile of an ENS name.""" type ProfileSocialAccount { - """The normalized social handle extracted from the raw record.""" + """The handle of the social account.""" handle: String! - """The canonical HTTP-compatible social profile URL.""" + """The HTTP-compatible url to the social account.""" httpUrl: String! } -""" -Interpreted social accounts on a Name profile. Each field returns null independently when its raw record cannot be interpreted. -""" +"""The interpreted social accounts on the profile of an ENS name.""" type ProfileSocials { """ The interpreted GitHub account. Returns null when the raw record is unset, empty, or cannot be parsed as a GitHub handle or profile URL. @@ -1398,9 +1384,7 @@ type ProfileSocials { twitter: ProfileSocialAccount } -""" -Interpreted website metadata. Returns null when the raw url record is unset, empty, or cannot be parsed as a valid http(s) URL. -""" +"""The interpreted website on the profile of an ENS name.""" type ProfileWebsite { """ The HTTP-compatible website URL. Returns null when the raw url record is unset, empty, not an http(s) URL, or cannot be parsed as a valid URL.