diff --git a/.changeset/domain-profile-omnigraph.md b/.changeset/domain-profile-omnigraph.md new file mode 100644 index 0000000000..a11332c89f --- /dev/null +++ b/.changeset/domain-profile-omnigraph.md @@ -0,0 +1,5 @@ +--- +"ensapi": patch +--- + +**Omnigraph API:** Introduces `Domain.resolve.profile` and `PrimaryNameRecord.resolve.profile` for resolving semantic record values. 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 5d49aa8682..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 } from "@ensnode/datasources/devnet"; +import { + accounts, + addresses, + fixtures, + testEthTextRecords, +} from "@ensnode/integration-test-env/devnet"; const BASE_URL = process.env.ENSNODE_URL!; @@ -60,7 +65,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 +164,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 +194,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 +211,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 +224,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 fe1b81b556..3dcbf13d51 100644 --- a/apps/ensapi/src/omnigraph-api/builder.ts +++ b/apps/ensapi/src/omnigraph-api/builder.ts @@ -8,14 +8,21 @@ import { AttributeNames, createOpenTelemetryWrapper } from "@pothos/tracing-open import type { BeautifiedLabel, BeautifiedName, + BinanceAddress, + BitcoinAddress, + BitcoinCashAddress, ChainId, CoinType, + DogecoinAddress, DomainId, + Email, Hex, InterfaceId, InterpretedLabel, InterpretedName, JsonValue, + LitecoinAddress, + MonacoinAddress, Node, NormalizedAddress, PermissionsId, @@ -26,6 +33,9 @@ import type { RenewalId, ResolverId, ResolverRecordsId, + RippleAddress, + RootstockAddress, + SolanaAddress, } from "enssdk"; import { getNamedType } from "graphql"; import superjson from "superjson"; @@ -65,6 +75,16 @@ 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 }; + 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 }; 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..961a9abd45 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/README.md @@ -0,0 +1,79 @@ +# 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 `ProfileFieldInterpreter` that declares its record selection and interpretation logic. + +## Architecture + +``` +profile/ + build-profile-selection.ts # GraphQL selection → ResolverRecordsSelection + 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 + README.md +``` + +GraphQL wiring lives in `apps/ensapi/src/omnigraph-api/schema/profile.ts`. + +Each interpreter is a singleton with: + +- `selection` — which text keys / coin types must be fetched +- `interpret(result)` — derive the GraphQL output from `ResolvedRecordsModel` + +`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`. 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.test.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/build-profile-selection.test.ts new file mode 100644 index 0000000000..5b992d1c62 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/build-profile-selection.test.ts @@ -0,0 +1,166 @@ +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", "vnd.github"] }, + ], + [ + "socials.twitter", + "profile { socials { twitter { handle httpUrl } } }", + { texts: ["com.x", "com.twitter", "vnd.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", + "vnd.github", + "com.x", + "com.twitter", + "vnd.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 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 + 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..625d41c33b --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/build-profile-selection.ts @@ -0,0 +1,123 @@ +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_INTERPRETERS, + ProfileAvatarInterpreter, + ProfileDescriptionInterpreter, + ProfileEmailInterpreter, + ProfileHeaderInterpreter, + ProfileWebsiteInterpreter, + SOCIAL_INTERPRETERS, +} from "./interpreters"; + +/** 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, ProfileDescriptionInterpreter.selection); + } + if (topLevelFields.has("avatar")) { + merged = mergeRecordsSelections(merged, ProfileAvatarInterpreter.selection); + } + if (topLevelFields.has("header")) { + merged = mergeRecordsSelections(merged, ProfileHeaderInterpreter.selection); + } + if (topLevelFields.has("website")) { + merged = mergeRecordsSelections(merged, ProfileWebsiteInterpreter.selection); + } + if (topLevelFields.has("email")) { + 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, interpreter] of Object.entries(SOCIAL_INTERPRETERS)) { + if (socialFields.has(fieldName)) { + merged = mergeRecordsSelections(merged, interpreter.selection); + } + } + } + + // 4. Walk addresses sub-fields + const addressesNodes = collectSubFieldNodes(profileNodes, "addresses", info); + if (addressesNodes.length > 0) { + const addressFields = collectChildFieldNames(addressesNodes, info); + for (const [fieldName, interpreter] of Object.entries(ADDRESS_INTERPRETERS)) { + if (addressFields.has(fieldName)) { + merged = mergeRecordsSelections(merged, interpreter.selection); + } + } + } + + return merged; +} diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/addresses.test.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/addresses.test.ts new file mode 100644 index 0000000000..60baee4e2b --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/addresses.test.ts @@ -0,0 +1,96 @@ +import type { Hex } from "viem"; +import { describe, expect, it } from "vitest"; + +import { ADDRESS_INTERPRETERS } from "./addresses"; +import { profileRecordsModel } from "./test-helpers"; + +describe("ADDRESS_INTERPRETERS", () => { + it.each([ + [ + "ethereum", + 60, + "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", + "0xd8da6bf26964af9d7eed9e03e53415d37aa96045", + ], + [ + "base", + 2147492101, + "0xD8DA6BF26964AF9D7EED9E03E53415D37AA96045", + "0xd8da6bf26964af9d7eed9e03e53415d37aa96045", + ], + [ + "bitcoin", + 0, + "0x76a91462e907b15cbf27d5425399ebf6f0fb50ebb88f1888ac", + "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", + ], + [ + "solana", + 501, + "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_INTERPRETERS[field].selection).toEqual({ addresses: [coinType] }); + expect( + ADDRESS_INTERPRETERS[field].interpret(profileRecordsModel({}, { [coinType]: raw })), + ).toBe(expected); + }); + + it.each([ + ["record unset", undefined], + ["empty string", ""], + ["0x sentinel", "0x"], + ["non-hex value", "0xnot-hex"], + ] as const)("returns null: %s (%s)", (_message, raw) => { + 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(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/interpreters/images.test.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/images.test.ts new file mode 100644 index 0000000000..e524c5a691 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/images.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from "vitest"; + +import { ProfileAvatarInterpreter, ProfileHeaderInterpreter } from "./images"; +import { profileRecordsModel } from "./test-helpers"; + +vi.mock("@/di", () => ({ + default: { + context: { + namespace: "sepolia", + }, + }, +})); + +describe("ProfileAvatarInterpreter", () => { + it("has correct selection", () => { + expect(ProfileAvatarInterpreter.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(ProfileAvatarInterpreter.interpret(profileRecordsModel(texts))).toEqual(expected); + }); + + it.each([ + ["record unset", {}], + ["empty string", { avatar: "" }], + ])("returns null: %s", (_message, texts) => { + expect(ProfileAvatarInterpreter.interpret(profileRecordsModel(texts))).toBeNull(); + }); +}); + +describe("ProfileHeaderInterpreter", () => { + it("has correct selection", () => { + expect(ProfileHeaderInterpreter.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(ProfileHeaderInterpreter.interpret(profileRecordsModel(texts))).toEqual(expected); + }); + + it.each([ + ["record unset", {}], + ["empty string", { header: "" }], + ])("returns null: %s", (_message, texts) => { + expect(ProfileHeaderInterpreter.interpret(profileRecordsModel(texts))).toBeNull(); + }); +}); diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/images.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/images.ts new file mode 100644 index 0000000000..921de2978f --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/images.ts @@ -0,0 +1,59 @@ +import { type EnsMetadataImageRecord, getEnsMetadataServiceImageUrl } from "enssdk"; + +import di from "@/di"; +import type { ResolvedRecordsModel } from "@/omnigraph-api/lib/resolution/records-profile-model"; + +import type { ProfileFieldInterpreter } 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 buildImageInterpreter = ( + record: EnsMetadataImageRecord, +): ProfileFieldInterpreter => ({ + selection: { texts: [record] }, + interpret: (result) => { + const raw = result.records.texts?.[record]?.trim(); + if (!raw) return null; + + const httpUrl = + parseDirectImageHttpUrl(raw) ?? interpretProfileImageHttpUrl(result, raw, record); + + return { httpUrl }; + }, +}); + +export const ProfileAvatarInterpreter: ProfileFieldInterpreter = + buildImageInterpreter("avatar"); +export const ProfileHeaderInterpreter: ProfileFieldInterpreter = + buildImageInterpreter("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.name, di.context.namespace, record)?.href ?? null; +} 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/interpreters/social.test.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/social.test.ts new file mode 100644 index 0000000000..ccb5d40807 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/social.test.ts @@ -0,0 +1,305 @@ +import { describe, expect, it } from "vitest"; + +import { + SocialGithubInterpreter, + SocialKeybaseInterpreter, + SocialLinkedInInterpreter, + SocialTelegramInterpreter, + SocialTwitterInterpreter, +} from "./social"; +import { profileRecordsModel } from "./test-helpers"; +import type { ProfileFieldInterpreter } from "./types"; + +type SocialResult = { handle: string; httpUrl: string }; + +/** + * Generates common test cases shared across social interpreters: + * 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 interpreters. + */ +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 describeSocialInterpreter( + name: string, + interpreter: ProfileFieldInterpreter, + 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(interpreter.selection).toEqual(selection); + }); + + 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(interpreter.interpret(profileRecordsModel(texts))).toBeNull(); + }); + }); +} + +// --- GitHub --- + +const EXPECTED_GITHUB: SocialResult = { + handle: "itslevchiks", + httpUrl: "https://github.com/itslevchiks", +}; + +describeSocialInterpreter("SocialGithubInterpreter", SocialGithubInterpreter, "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", + { handle: "itslevchiks", httpUrl: "https://github.com/itslevchiks?tab=repos" }, + ], + [ + "hash fragment", + "https://github.com/itslevchiks#readme", + { handle: "itslevchiks", httpUrl: "https://github.com/itslevchiks#readme" }, + ], + [ + "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" }, + ], + ], + nullCases: [ + ["invalid handle characters", { "com.github": "invalid user name!" }], + ["foreign social URL", { "com.github": "https://twitter.com/itslevchiks" }], + ], +}); + +// --- Twitter --- + +const EXPECTED_TWITTER: SocialResult = { + handle: "itslevchiks", + httpUrl: "https://x.com/itslevchiks", +}; + +describeSocialInterpreter("SocialTwitterInterpreter", SocialTwitterInterpreter, "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" }, + ], + ], + nullCases: [ + ["invalid handle characters", { "com.x": "hello world" }], + ["foreign social URL", { "com.x": "https://github.com/itslevchiks" }], + ], +}); + +// --- Telegram --- + +const EXPECTED_TELEGRAM: SocialResult = { + handle: "itslevchiks", + httpUrl: "https://t.me/itslevchiks", +}; + +describeSocialInterpreter("SocialTelegramInterpreter", SocialTelegramInterpreter, "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" }], + ], +}); + +// --- LinkedIn --- + +const EXPECTED_LINKEDIN: SocialResult = { + handle: "itslevchiks", + httpUrl: "https://www.linkedin.com/in/itslevchiks", +}; + +describeSocialInterpreter("SocialLinkedInInterpreter", SocialLinkedInInterpreter, "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", +}; + +describeSocialInterpreter("SocialKeybaseInterpreter", SocialKeybaseInterpreter, "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("SocialGithubInterpreter (vnd.github fallback)", () => { + it("falls back to vnd.github when com.github is unset", () => { + expect( + SocialGithubInterpreter.interpret(profileRecordsModel({ "vnd.github": "itslevchiks" })), + ).toEqual(EXPECTED_GITHUB); + }); + + it("prefers com.github over vnd.github", () => { + expect( + SocialGithubInterpreter.interpret( + 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( + SocialGithubInterpreter.interpret( + profileRecordsModel({ "com.github": "", "vnd.github": "legacy-user" }), + ), + ).toEqual({ handle: "legacy-user", httpUrl: "https://github.com/legacy-user" }); + }); +}); + +describe("SocialTwitterInterpreter (text key fallbacks)", () => { + it("falls back to com.twitter when com.x is unset", () => { + expect( + SocialTwitterInterpreter.interpret(profileRecordsModel({ "com.twitter": "itslevchiks" })), + ).toEqual(EXPECTED_TWITTER); + }); + + it("prefers com.x over com.twitter", () => { + expect( + SocialTwitterInterpreter.interpret( + 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( + 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( + SocialTwitterInterpreter.interpret(profileRecordsModel({ "vnd.twitter": "itslevchiks" })), + ).toEqual(EXPECTED_TWITTER); + }); + + it("prefers com.twitter over vnd.twitter", () => { + expect( + SocialTwitterInterpreter.interpret( + profileRecordsModel({ "com.twitter": "primaryuser", "vnd.twitter": "legacyuser" }), + ), + ).toEqual({ handle: "primaryuser", httpUrl: "https://x.com/primaryuser" }); + }); + + it("falls back to vnd.twitter when com.x and com.twitter are empty", () => { + expect( + 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/interpreters/social.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/social.ts new file mode 100644 index 0000000000..cb58aa0344 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/social.ts @@ -0,0 +1,147 @@ +import type { ProfileFieldInterpreter } 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; +}; + +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. + * + * Tolerates several input shapes: + * - Bare handle: `itslevchiks` + * - With leading @: `@itslevchiks` + * - Full URL: `https://github.com/itslevchiks`, `http://github.com/itslevchiks` + * - Repo URL: `https://github.com/itslevchiks/my-repo` + * - URL without scheme: `github.com/itslevchiks` + * + * 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 pathOffset = pathOffsetFromBaseUrl(baseUrl); + 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.slice(pathOffset).join("/"); + handle = handle === "" ? null : handle; + + 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 socialInterpreter = ( + textKeys: string | readonly string[], + hostnames: readonly string[], + baseUrl: string, + handlePattern: RegExp, +): ProfileFieldInterpreter => { + const keys = typeof textKeys === "string" ? [textKeys] : [...textKeys]; + const opts = { hostnames, baseUrl, handlePattern }; + return { + selection: { texts: keys }, + interpret: (result) => { + for (const key of keys) { + const parsed = parseSocialHandle({ value: result.records.texts?.[key], ...opts }); + if (parsed !== null) return parsed; + } + return null; + }, + }; +}; + +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/interpreters/test-helpers.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/test-helpers.ts new file mode 100644 index 0000000000..1dad2494f9 --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/test-helpers.ts @@ -0,0 +1,15 @@ +import { asInterpretedName } from "enssdk"; +import type { Hex } from "viem"; + +import type { ResolvedRecordsModel } from "@/omnigraph-api/lib/resolution/records-profile-model"; + +export const profileRecordsModel = ( + texts?: Record, + addresses?: Record, +): ResolvedRecordsModel => ({ + name: asInterpretedName("test.eth"), + records: { + texts: texts ?? {}, + addresses: addresses ?? {}, + }, +}); diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/texts.test.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/texts.test.ts new file mode 100644 index 0000000000..c108f3125b --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/texts.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; + +import { profileRecordsModel } from "./test-helpers"; +import { + ProfileDescriptionInterpreter, + ProfileEmailInterpreter, + ProfileWebsiteInterpreter, +} from "./texts"; + +describe("ProfileDescriptionInterpreter", () => { + it("has correct selection", () => { + expect(ProfileDescriptionInterpreter.selection).toEqual({ texts: ["description"] }); + }); + + it.each([ + ["plain text", { description: "Hello" }, "Hello"], + ["whitespace preserved", { description: " Hello " }, " Hello "], + ])("parses %s", (_message, texts, expected) => { + expect(ProfileDescriptionInterpreter.interpret(profileRecordsModel(texts))).toBe(expected); + }); + + it.each([ + ["record unset", {}], + ["empty string", { description: "" }], + ])("returns null: %s", (_message, texts) => { + expect(ProfileDescriptionInterpreter.interpret(profileRecordsModel(texts))).toBeNull(); + }); +}); + +describe("ProfileWebsiteInterpreter", () => { + it("has correct selection", () => { + 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(ProfileWebsiteInterpreter.interpret(profileRecordsModel(texts))).toBe(expected); + }); + + 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(ProfileWebsiteInterpreter.interpret(profileRecordsModel(texts))).toBeNull(); + }); + + it("trims surrounding whitespace before parsing", () => { + expect( + ProfileWebsiteInterpreter.interpret(profileRecordsModel({ url: " https://example.com " })), + ).toBe("https://example.com"); + }); +}); + +describe("ProfileEmailInterpreter", () => { + it("has correct selection", () => { + 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(ProfileEmailInterpreter.interpret(profileRecordsModel(texts))).toBe(expected); + }); + + 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(ProfileEmailInterpreter.interpret(profileRecordsModel(texts))).toBeNull(); + }); + + it("trims surrounding whitespace from valid email", () => { + expect( + ProfileEmailInterpreter.interpret(profileRecordsModel({ email: " user@example.com " })), + ).toBe("user@example.com"); + }); +}); 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 new file mode 100644 index 0000000000..40618e8b3e --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/texts.ts @@ -0,0 +1,49 @@ +import type { Email } from "enssdk"; + +import { makeEmailSchema } from "@ensnode/ensnode-sdk/internal"; + +import type { ProfileFieldInterpreter } from "./types"; + +const profileEmailSchema = makeEmailSchema("email text record"); + +const textInterpreter = (key: string): ProfileFieldInterpreter => ({ + selection: { texts: [key] }, + interpret: (result) => { + const raw = result.records.texts?.[key]; + if (raw == null || raw === "") return null; + return raw; + }, +}); + +export const ProfileDescriptionInterpreter: ProfileFieldInterpreter = + textInterpreter("description"); + +export const ProfileEmailInterpreter: ProfileFieldInterpreter = { + selection: { texts: ["email"] }, + interpret: (result) => { + const raw = result.records.texts?.email?.trim(); + if (!raw) return null; + const parsed = profileEmailSchema.safeParse(raw); + return parsed.success ? parsed.data : null; + }, +}; + +const httpUrlInterpreter = (key: string): ProfileFieldInterpreter => ({ + selection: { texts: [key] }, + interpret: (result) => { + const trimmed = result.records.texts?.[key]?.trim(); + if (!trimmed) return null; + if (!trimmed.startsWith("http://") && !trimmed.startsWith("https://")) { + return null; + } + + try { + new URL(trimmed); + return trimmed; + } catch { + return null; + } + }, +}); + +export const ProfileWebsiteInterpreter: ProfileFieldInterpreter = httpUrlInterpreter("url"); diff --git a/apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/types.ts b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/types.ts new file mode 100644 index 0000000000..75b793af1c --- /dev/null +++ b/apps/ensapi/src/omnigraph-api/lib/resolution/profile/interpreters/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 `ProfileFieldInterpreter`. The parent resolver + * passes the shared `ResolvedRecordsModel` to `interpret`, keeping all resolution in one round-trip. + */ +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. */ + interpret(result: ResolvedRecordsModel): TOutput | null; +} 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..e6590ba2e8 --- /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 = + "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( + "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 = + "The interpreted addresses on the profile of an ENS name."; + +export const profileSocialsContainerDescription = + "The interpreted social accounts on the profile of an ENS name."; 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 552743d946..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 @@ -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,48 @@ 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], + }); + }); + + 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 343678c162..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, @@ -128,6 +128,41 @@ function buildRecordsSelectionFromRecordsFieldNodes( return recordsSelection; } +/** + * Merges two nullable {@link ResolverRecordsSelection} objects into one. + * + * - `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. + */ +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 ? uniq([...(a.texts ?? []), ...(b.texts ?? [])]) : undefined, + addresses: + 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 + ? uniq([...(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/account.integration.test.ts b/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts index f34d7f4a24..4362d64067 100644 --- a/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/account.integration.test.ts @@ -1,8 +1,14 @@ -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"; -import { accounts } from "@ensnode/datasources/devnet"; +import { accounts, testEthTextRecords } from "@ensnode/integration-test-env/devnet"; import { AccountDomainsPaginated, @@ -328,6 +334,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: UrlString | null } | null; + } | null; } | null; }; @@ -444,6 +454,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 +593,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 df9c6aa712..4757131f56 100644 --- a/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts +++ b/apps/ensapi/src/omnigraph-api/schema/domain.integration.test.ts @@ -16,14 +16,19 @@ import { makeENSv2RegistryId, makeStorageId, type NormalizedAddress, + type UrlString, } from "enssdk"; import { beforeAll, describe, expect, it } from "vitest"; import { DatasourceNames } from "@ensnode/datasources"; -import { accounts, addresses, fixtures } from "@ensnode/datasources/devnet"; import { getDatasourceContract } from "@ensnode/ensnode-sdk"; +import { + accounts, + addresses, + fixtures, + testEthTextRecords, +} from "@ensnode/integration-test-env/devnet"; -import { INCLUDE_DEV_METHODS } from "@/omnigraph-api/lib/include-dev-methods"; import { DEVNET_ETH_LABELS, DEVNET_NAMES } from "@/test/integration/devnet-names"; import { DomainSubdomainsPaginated, @@ -561,9 +566,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, { @@ -588,7 +590,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], @@ -605,16 +616,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, ], }, }, @@ -682,16 +701,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: UrlString | null } | null; addresses: { ethereum: NormalizedAddress | null } | null; - socials: { github: { handle: string | null; url: string | null } | null } | null; + socials: { github: { handle: string; httpUrl: UrlString } | null } | null; } | null; }; }; @@ -703,29 +721,82 @@ describe("Domain.records", () => { resolve { profile { description - avatar { url } - addresses { ethereum } - socials { github { handle url } } + avatar { httpUrl } + header { httpUrl } + website { httpUrl } + email + addresses { ethereum bitcoin litecoin solana } + socials { + github { httpUrl handle } + twitter { httpUrl handle } + telegram { httpUrl handle } + } } } } } `; - 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: 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", + }, + }, }, }, }, }); }); + + 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..25cd91262c 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, @@ -197,23 +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 recordsSelection = buildRecordsSelectionFromResolveContainerInfo(info); - if (!recordsSelection) { - return { accelerate, canAccelerate, trace: null, records: null }; + const selection = mergeRecordsSelections( + buildRecordsSelectionFromResolveContainerInfo(info), + buildProfileSelectionFromResolveContainerInfo(info), + ); + + if (!selection) { + return { accelerate, canAccelerate, trace: null, result: null }; } const { trace, result } = await runWithTrace(() => - resolveForward(name, recordsSelection, { 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 37f97944f6..feed1204d8 100644 --- a/apps/ensapi/src/omnigraph-api/schema/forward-resolve.ts +++ b/apps/ensapi/src/omnigraph-api/schema/forward-resolve.ts @@ -3,8 +3,7 @@ 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 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"; @@ -13,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"); @@ -43,16 +42,14 @@ ForwardResolveRef.implement({ type: ResolvedRecordsRef, nullable: true, tracing: true, - resolve: (parent) => parent.records, + resolve: (parent) => parent.result, }), - ...(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: + "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/primary-name-record.ts b/apps/ensapi/src/omnigraph-api/schema/primary-name-record.ts index edcdef198c..1e42ef3556 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, @@ -59,23 +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 recordsSelection = buildRecordsSelectionFromResolveContainerInfo(info); - if (!recordsSelection) { - return { accelerate, canAccelerate, trace: null, records: null }; + const selection = mergeRecordsSelections( + buildRecordsSelectionFromResolveContainerInfo(info), + buildProfileSelectionFromResolveContainerInfo(info), + ); + + if (!selection) { + return { accelerate, canAccelerate, trace: null, result: null }; } const { trace, result } = await runWithTrace(() => - resolveForward(name, recordsSelection, { 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/profile.ts b/apps/ensapi/src/omnigraph-api/schema/profile.ts index 1803adf379..314984b0af 100644 --- a/apps/ensapi/src/omnigraph-api/schema/profile.ts +++ b/apps/ensapi/src/omnigraph-api/schema/profile.ts @@ -1,153 +1,241 @@ import { builder } from "@/omnigraph-api/builder"; +import { + ADDRESS_INTERPRETERS, + ProfileAvatarInterpreter, + ProfileDescriptionInterpreter, + ProfileEmailInterpreter, + ProfileHeaderInterpreter, + ProfileWebsiteInterpreter, + SOCIAL_INTERPRETERS, +} from "@/omnigraph-api/lib/resolution/profile/interpreters"; +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"; -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 the profile of an ENS name.", fields: (t) => ({ - handle: t.string({ - description: "The social handle, or null when unset.", - nullable: true, - resolve: () => null, + handle: t.exposeString("handle", { + description: "The handle of the social account.", + 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 url to the social account.", + 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: profileSocialsContainerDescription, fields: (t) => ({ github: t.field({ + description: profileSocialFieldDescription("GitHub"), type: ProfileSocialAccountRef, nullable: true, - resolve: () => ({}), + resolve: (model) => SOCIAL_INTERPRETERS.github.interpret(model), }), telegram: t.field({ + description: profileSocialFieldDescription("Telegram"), type: ProfileSocialAccountRef, nullable: true, - resolve: () => ({}), + resolve: (model) => SOCIAL_INTERPRETERS.telegram.interpret(model), }), twitter: t.field({ + description: profileSocialFieldDescription("X (Twitter)"), + type: ProfileSocialAccountRef, + nullable: true, + resolve: (model) => SOCIAL_INTERPRETERS.twitter.interpret(model), + }), + linkedin: t.field({ + description: profileSocialFieldDescription("LinkedIn"), + type: ProfileSocialAccountRef, + nullable: true, + resolve: (model) => SOCIAL_INTERPRETERS.linkedin.interpret(model), + }), + keybase: t.field({ + description: profileSocialFieldDescription("Keybase"), type: ProfileSocialAccountRef, nullable: true, - resolve: () => ({}), + resolve: (model) => SOCIAL_INTERPRETERS.keybase.interpret(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: profileAddressesContainerDescription, fields: (t) => ({ ethereum: t.field({ - description: "The interpreted Ethereum address, or null when unset.", + description: profileAddressFieldDescription("Ethereum"), type: "Address", nullable: true, - resolve: () => null, + resolve: (model) => ADDRESS_INTERPRETERS.ethereum.interpret(model), }), base: t.field({ - description: "The interpreted Base address, or null when unset.", + description: profileAddressFieldDescription("Base"), type: "Address", nullable: true, - resolve: () => null, + resolve: (model) => ADDRESS_INTERPRETERS.base.interpret(model), + }), + bitcoin: t.field({ + description: profileAddressFieldDescription("Bitcoin"), + type: "BitcoinAddress", + nullable: true, + resolve: (model) => ADDRESS_INTERPRETERS.bitcoin.interpret(model), + }), + solana: t.field({ + description: profileAddressFieldDescription("Solana"), + type: "SolanaAddress", + nullable: true, + resolve: (model) => ADDRESS_INTERPRETERS.solana.interpret(model), }), - bitcoin: t.string({ - description: "The interpreted Bitcoin address, or null when unset.", + litecoin: t.field({ + description: profileAddressFieldDescription("Litecoin"), + type: "LitecoinAddress", nullable: true, - resolve: () => null, + resolve: (model) => ADDRESS_INTERPRETERS.litecoin.interpret(model), }), - solana: t.string({ - description: "The interpreted Solana address, or null when unset.", + dogecoin: t.field({ + description: profileAddressFieldDescription("Dogecoin"), + type: "DogecoinAddress", nullable: true, - resolve: () => null, + resolve: (model) => ADDRESS_INTERPRETERS.dogecoin.interpret(model), + }), + monacoin: t.field({ + description: profileAddressFieldDescription("Monacoin"), + type: "MonacoinAddress", + nullable: true, + resolve: (model) => ADDRESS_INTERPRETERS.monacoin.interpret(model), + }), + rootstock: t.field({ + description: profileAddressFieldDescription("Rootstock (RBTC)"), + type: "RootstockAddress", + nullable: true, + resolve: (model) => ADDRESS_INTERPRETERS.rootstock.interpret(model), + }), + ripple: t.field({ + description: profileAddressFieldDescription("Ripple (XRP)"), + type: "RippleAddress", + nullable: true, + resolve: (model) => ADDRESS_INTERPRETERS.ripple.interpret(model), + }), + bitcoincash: t.field({ + description: profileAddressFieldDescription("Bitcoin Cash"), + type: "BitcoinCashAddress", + nullable: true, + resolve: (model) => ADDRESS_INTERPRETERS.bitcoincash.interpret(model), + }), + binance: t.field({ + description: profileAddressFieldDescription("Binance Chain (BNB)"), + type: "BinanceAddress", + nullable: true, + resolve: (model) => ADDRESS_INTERPRETERS.binance.interpret(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: "The interpreted avatar image on the profile of an ENS name.", fields: (t) => ({ - url: t.string({ - description: "The resolved avatar URL, or null when unset.", + httpUrl: t.exposeString("httpUrl", { + description: profileImageHttpUrlFieldDescription("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: "The interpreted header image on the profile of an ENS name.", fields: (t) => ({ - url: t.string({ - description: "The resolved banner URL, or null when unset.", + httpUrl: t.exposeString("httpUrl", { + description: profileImageHttpUrlFieldDescription("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: profileWebsiteFieldDescription, fields: (t) => ({ - url: t.string({ - description: "The resolved website URL, or null when unset.", + httpUrl: t.string({ + 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: () => null, + resolve: (model) => ProfileWebsiteInterpreter.interpret(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: "The interpreted profile of an ENS name.", 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: () => ({}), + resolve: (model) => ProfileAvatarInterpreter.interpret(model), }), - banner: t.field({ - type: ProfileBannerRef, + header: t.field({ + description: + "Interpreted header metadata. Returns null when the raw header record is unset or empty.", + type: ProfileHeaderRef, nullable: true, - resolve: () => ({}), + resolve: (model) => ProfileHeaderInterpreter.interpret(model), }), website: t.field({ + description: profileWebsiteFieldDescription, type: ProfileWebsiteRef, nullable: true, - resolve: () => ({}), + resolve: (model) => (ProfileWebsiteInterpreter.interpret(model) ? model : null), }), description: t.string({ - description: "The profile description, or null when unset.", + 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 interpreted email address on the profile of an ENS name, or null when unset or invalid.", + type: "Email", nullable: true, - resolve: () => null, + resolve: (model) => ProfileEmailInterpreter.interpret(model), }), addresses: t.field({ + description: profileAddressesContainerDescription, type: ProfileAddressesRef, nullable: true, - resolve: () => ({}), + resolve: (model) => model, }), socials: t.field({ + description: profileSocialsContainerDescription, type: ProfileSocialsRef, nullable: true, - resolve: () => ({}), + resolve: (model) => model, }), }), }); 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/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/apps/ensapi/src/omnigraph-api/schema/scalars.ts b/apps/ensapi/src/omnigraph-api/schema/scalars.ts index 2b41126c32..62070c0f18 100644 --- a/apps/ensapi/src/omnigraph-api/schema/scalars.ts +++ b/apps/ensapi/src/omnigraph-api/schema/scalars.ts @@ -1,9 +1,15 @@ +import { type CoinName, coinNameToTypeMap, 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 Email, type Hex, type InterfaceId, type InterpretedLabel, @@ -12,6 +18,8 @@ import { isInterpretedLabel, isInterpretedName, type JsonValue, + type LitecoinAddress, + type MonacoinAddress, type Name, type Node, type NormalizedAddress, @@ -23,6 +31,9 @@ import { type RenewalId, type ResolverId, type ResolverRecordsId, + type RippleAddress, + type RootstockAddress, + type SolanaAddress, } from "enssdk"; import { isHex, size } from "viem"; import { z } from "zod/v4"; @@ -30,6 +41,7 @@ import { z } from "zod/v4"; import { makeChainIdSchema, makeCoinTypeSchema, + makeEmailSchema, makeNormalizedAddressSchema, } from "@ensnode/ensnode-sdk/internal"; @@ -53,6 +65,83 @@ 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 = (coinName: CoinName, label: string) => { + const coinType = coinNameToTypeMap[coinName]; + return 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 ${coinNameToTypeMap.btc}).`, + serialize: (value: BitcoinAddress) => value, + parseValue: (value) => makeCoinAddressSchema("btc", "Bitcoin").parse(value) as BitcoinAddress, +}); + +builder.scalarType("SolanaAddress", { + description: `SolanaAddress represents a Base58-encoded Solana address (coin type ${coinNameToTypeMap.sol}).`, + serialize: (value: SolanaAddress) => value, + parseValue: (value) => makeCoinAddressSchema("sol", "Solana").parse(value) as SolanaAddress, +}); + +builder.scalarType("LitecoinAddress", { + description: `LitecoinAddress represents a Base58Check-encoded Litecoin address (coin type ${coinNameToTypeMap.ltc}).`, + serialize: (value: LitecoinAddress) => value, + parseValue: (value) => makeCoinAddressSchema("ltc", "Litecoin").parse(value) as LitecoinAddress, +}); + +builder.scalarType("DogecoinAddress", { + description: `DogecoinAddress represents a Base58Check-encoded Dogecoin address (coin type ${coinNameToTypeMap.doge}).`, + serialize: (value: DogecoinAddress) => value, + parseValue: (value) => makeCoinAddressSchema("doge", "Dogecoin").parse(value) as DogecoinAddress, +}); + +builder.scalarType("MonacoinAddress", { + description: `MonacoinAddress represents a Base58Check-encoded Monacoin address (coin type ${coinNameToTypeMap.mona}).`, + serialize: (value: MonacoinAddress) => value, + parseValue: (value) => makeCoinAddressSchema("mona", "Monacoin").parse(value) as MonacoinAddress, +}); + +builder.scalarType("RootstockAddress", { + description: `RootstockAddress represents an EIP-55 checksummed Rootstock (RBTC) address (coin type ${coinNameToTypeMap.rbtc}).`, + serialize: (value: RootstockAddress) => value, + parseValue: (value) => + makeCoinAddressSchema("rbtc", "Rootstock").parse(value) as RootstockAddress, +}); + +builder.scalarType("RippleAddress", { + description: `RippleAddress represents a Base58Check-encoded Ripple (XRP) address (coin type ${coinNameToTypeMap.xrp}).`, + serialize: (value: RippleAddress) => value, + parseValue: (value) => makeCoinAddressSchema("xrp", "Ripple").parse(value) as RippleAddress, +}); + +builder.scalarType("BitcoinCashAddress", { + description: `BitcoinCashAddress represents a CashAddr-encoded Bitcoin Cash address (coin type ${coinNameToTypeMap.bch}).`, + serialize: (value: BitcoinCashAddress) => value, + parseValue: (value) => + makeCoinAddressSchema("bch", "Bitcoin Cash").parse(value) as BitcoinCashAddress, +}); + +builder.scalarType("BinanceAddress", { + description: `BinanceAddress represents a Bech32-encoded Binance Chain (BNB) address (coin type ${coinNameToTypeMap.bnb}).`, + serialize: (value: BinanceAddress) => value, + parseValue: (value) => makeCoinAddressSchema("bnb", "Binance").parse(value) as BinanceAddress, +}); + builder.scalarType("Hex", { description: "Hex represents viem#Hex.", serialize: (value: Hex) => value, diff --git a/packages/datasources/src/devnet/constants.ts b/packages/datasources/src/devnet/constants.ts index e1c942f07f..8886f15106 100644 --- a/packages/datasources/src/devnet/constants.ts +++ b/packages/datasources/src/devnet/constants.ts @@ -1,7 +1,4 @@ import type { NormalizedAddress } from "enssdk"; -import { asNormalizedAddress, toNormalizedAddress } from "enssdk"; -import type { Hex } from "viem"; -import { mnemonicToAccount } from "viem/accounts"; /** * Deterministic contract addresses for the ENS contracts-v2 devnet used by ens-test-env. @@ -92,49 +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; - -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; diff --git a/packages/enskit/src/react/omnigraph/_lib/cache-exchange.ts b/packages/enskit/src/react/omnigraph/_lib/cache-exchange.ts index c4a2919432..0190434f77 100644 --- a/packages/enskit/src/react/omnigraph/_lib/cache-exchange.ts +++ b/packages/enskit/src/react/omnigraph/_lib/cache-exchange.ts @@ -52,7 +52,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/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 de2675e9e3..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"; @@ -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..124a304af6 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, @@ -150,6 +151,16 @@ 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 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); + /** * Parses a serialized representation of an EVM address into a {@link NormalizedAddress}. */ 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..df85b415d3 --- /dev/null +++ b/packages/enssdk/src/lib/ens-metadata-service.test.ts @@ -0,0 +1,34 @@ +import { asInterpretedName, type Name } from "enssdk"; +import { describe, expect, it } from "vitest"; + +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", + ); + }); + + it("returns null for unsupported namespaces", () => { + 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(); + }); +}); 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..e4dfd37210 --- /dev/null +++ b/packages/enssdk/src/lib/ens-metadata-service.ts @@ -0,0 +1,48 @@ +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]; + +const URI_SCHEME_PATTERN = /^[a-zA-Z][a-zA-Z0-9+.-]*:/; + +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 { + // `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); + if (!network) return null; + + return new URL(name, `https://metadata.ens.domains/${network}/${record}/`); +} 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..d1738481e3 --- /dev/null +++ b/packages/enssdk/src/lib/types/addresses.ts @@ -0,0 +1,44 @@ +/** + * Base58Check-encoded Bitcoin address (SLIP-44 coin type 0). + */ +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). + */ +export type SolanaAddress = 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..9e8196370f --- /dev/null +++ b/packages/enssdk/src/lib/types/email.ts @@ -0,0 +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; diff --git a/packages/enssdk/src/lib/types/index.ts b/packages/enssdk/src/lib/types/index.ts index 220c1e2639..4e3f59da04 100644 --- a/packages/enssdk/src/lib/types/index.ts +++ b/packages/enssdk/src/lib/types/index.ts @@ -1,5 +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 06071e4368..37377f6f86 100644 --- a/packages/enssdk/src/omnigraph/generated/introspection.ts +++ b/packages/enssdk/src/omnigraph/generated/introspection.ts @@ -1105,6 +1105,18 @@ const introspection = { "kind": "SCALAR", "name": "BigInt" }, + { + "kind": "SCALAR", + "name": "BinanceAddress" + }, + { + "kind": "SCALAR", + "name": "BitcoinAddress" + }, + { + "kind": "SCALAR", + "name": "BitcoinCashAddress" + }, { "kind": "SCALAR", "name": "Boolean" @@ -1178,6 +1190,10 @@ const introspection = { "kind": "SCALAR", "name": "CoinType" }, + { + "kind": "SCALAR", + "name": "DogecoinAddress" + }, { "kind": "INTERFACE", "name": "Domain", @@ -1674,19 +1690,28 @@ const introspection = { "isDeprecated": false }, { - "name": "banner", + "name": "description", "type": { - "kind": "OBJECT", - "name": "ProfileBanner" + "kind": "SCALAR", + "name": "String" }, "args": [], "isDeprecated": false }, { - "name": "description", + "name": "email", "type": { "kind": "SCALAR", - "name": "String" + "name": "Email" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "header", + "type": { + "kind": "OBJECT", + "name": "ProfileHeader" }, "args": [], "isDeprecated": false @@ -3456,6 +3481,10 @@ const introspection = { } ] }, + { + "kind": "SCALAR", + "name": "Email" + }, { "kind": "OBJECT", "name": "Event", @@ -3801,6 +3830,15 @@ const introspection = { "args": [], "isDeprecated": false }, + { + "name": "profile", + "type": { + "kind": "OBJECT", + "name": "DomainProfile" + }, + "args": [], + "isDeprecated": false + }, { "name": "records", "type": { @@ -3893,6 +3931,14 @@ const introspection = { ], "interfaces": [] }, + { + "kind": "SCALAR", + "name": "LitecoinAddress" + }, + { + "kind": "SCALAR", + "name": "MonacoinAddress" + }, { "kind": "INPUT_OBJECT", "name": "NameOrNodeInput", @@ -4951,11 +4997,38 @@ const introspection = { "args": [], "isDeprecated": false }, + { + "name": "binance", + "type": { + "kind": "SCALAR", + "name": "BinanceAddress" + }, + "args": [], + "isDeprecated": false + }, { "name": "bitcoin", "type": { "kind": "SCALAR", - "name": "String" + "name": "BitcoinAddress" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "bitcoincash", + "type": { + "kind": "SCALAR", + "name": "BitcoinCashAddress" + }, + "args": [], + "isDeprecated": false + }, + { + "name": "dogecoin", + "type": { + "kind": "SCALAR", + "name": "DogecoinAddress" }, "args": [], "isDeprecated": false @@ -4969,11 +5042,47 @@ 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": { "kind": "SCALAR", - "name": "String" + "name": "SolanaAddress" }, "args": [], "isDeprecated": false @@ -4986,7 +5095,7 @@ const introspection = { "name": "ProfileAvatar", "fields": [ { - "name": "url", + "name": "httpUrl", "type": { "kind": "SCALAR", "name": "String" @@ -4999,10 +5108,10 @@ const introspection = { }, { "kind": "OBJECT", - "name": "ProfileBanner", + "name": "ProfileHeader", "fields": [ { - "name": "url", + "name": "httpUrl", "type": { "kind": "SCALAR", "name": "String" @@ -5020,17 +5129,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 @@ -5051,6 +5166,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": { @@ -5077,7 +5210,7 @@ const introspection = { "name": "ProfileWebsite", "fields": [ { - "name": "url", + "name": "httpUrl", "type": { "kind": "SCALAR", "name": "String" @@ -6987,6 +7120,18 @@ const introspection = { ], "interfaces": [] }, + { + "kind": "SCALAR", + "name": "RippleAddress" + }, + { + "kind": "SCALAR", + "name": "RootstockAddress" + }, + { + "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 f9722a4aa1..f94400f849 100644 --- a/packages/enssdk/src/omnigraph/generated/schema.graphql +++ b/packages/enssdk/src/omnigraph/generated/schema.graphql @@ -242,6 +242,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 { """ @@ -273,6 +288,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. """ @@ -393,17 +413,35 @@ input DomainPermissionsWhereInput { user: DomainPermissionsUserFilter } -""" -PREVIEW: An interpreted ENS profile for a name. Types are defined for query ergonomics; resolution is not yet wired. -""" +"""The interpreted profile of an ENS name.""" type DomainProfile { + """The interpreted addresses on the profile of an ENS name.""" addresses: ProfileAddresses + + """ + Interpreted avatar metadata. Returns null when the raw avatar record is unset or empty. + """ avatar: ProfileAvatar - banner: ProfileBanner - """The profile description, or null when unset.""" + """ + The interpreted description on the profile of an ENS name, or null when unset. + """ description: String + + """ + The interpreted email address on the profile of an ENS name, or null when unset or invalid. + """ + email: Email + + """ + Interpreted header metadata. Returns null when the raw header record is unset or empty. + """ + header: ProfileHeader + + """The interpreted social accounts on the profile of an ENS name.""" socials: ProfileSocials + + """The interpreted website on the profile of an ENS name.""" website: ProfileWebsite } @@ -799,6 +837,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. """ @@ -938,6 +979,11 @@ type ForwardResolve { """ acceleration: AccelerationStatus! + """ + 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 + """ Resolved ENS records via the ENS protocol. Null when the name is not resolvable (non-canonical, unnormalized, or no records field was selected). """ @@ -987,6 +1033,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 @@ -1217,65 +1273,123 @@ input PrimaryNamesWhereInput @oneOf { coinTypes: [CoinType!] } -""" -PREVIEW: Interpreted address records on a Domain profile. Not yet resolved. -""" +"""The interpreted addresses on the profile of an ENS name.""" 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 Bitcoin address, or null when unset.""" - bitcoin: String + """ + 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 Ethereum 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. 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. 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. 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 Solana address, or null when unset.""" - solana: String + """ + 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. 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. 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. 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. 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 } -""" -PREVIEW: Interpreted avatar metadata on a Domain profile. Not yet resolved. -""" +"""The interpreted avatar image on the profile of an ENS name.""" type ProfileAvatar { - """The resolved avatar URL, or null when unset.""" - url: String + """ + 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 } -""" -PREVIEW: Interpreted banner metadata on a Domain profile. Not yet resolved. -""" -type ProfileBanner { - """The resolved banner URL, or null when unset.""" - url: String +"""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. + """ + httpUrl: String } -""" -PREVIEW: An interpreted social account on a Domain profile. Not yet resolved. -""" +"""An interpreted social account on the profile of an ENS name.""" type ProfileSocialAccount { - """The social handle, or null when unset.""" - handle: String + """The handle of the social account.""" + handle: String! - """The social profile URL, or null when unset.""" - url: String + """The HTTP-compatible url to the social account.""" + httpUrl: String! } -""" -PREVIEW: Interpreted social accounts on a Domain profile. Not yet resolved. -""" +"""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. + """ 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 } -""" -PREVIEW: Interpreted website metadata on a Domain profile. Not yet resolved. -""" +"""The interpreted website on the profile of an ENS name.""" type ProfileWebsite { - """The resolved website URL, or null when unset.""" - url: String + """ + 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 } type Query { @@ -1697,6 +1811,21 @@ 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). +""" +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..4373955af5 100644 --- a/packages/enssdk/src/omnigraph/graphql.ts +++ b/packages/enssdk/src/omnigraph/graphql.ts @@ -3,14 +3,21 @@ import { initGraphQLTada } from "gql.tada"; import type { BeautifiedLabel, BeautifiedName, + BinanceAddress, + BitcoinAddress, + BitcoinCashAddress, ChainId, CoinType, + DogecoinAddress, DomainId, + Email, Hex, InterfaceId, InterpretedLabel, InterpretedName, JsonValue, + LitecoinAddress, + MonacoinAddress, Node, NormalizedAddress, PermissionsId, @@ -21,6 +28,9 @@ import type { RenewalId, ResolverId, ResolverRecordsId, + RippleAddress, + RootstockAddress, + SolanaAddress, } from "../lib/types"; import type { introspection } from "./generated/introspection"; @@ -42,6 +52,16 @@ export type OmnigraphScalars = { BigInt: `${bigint}`; JSON: JsonValue; Address: NormalizedAddress; + Email: Email; + BitcoinAddress: BitcoinAddress; + LitecoinAddress: LitecoinAddress; + DogecoinAddress: DogecoinAddress; + MonacoinAddress: MonacoinAddress; + RootstockAddress: RootstockAddress; + RippleAddress: RippleAddress; + BitcoinCashAddress: BitcoinCashAddress; + BinanceAddress: BinanceAddress; + SolanaAddress: SolanaAddress; Hex: Hex; ChainId: ChainId; CoinType: CoinType; 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 1fb09c05c7..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"; @@ -24,19 +25,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/packages/namehash-ui/src/components/identity/EnsAvatar.tsx b/packages/namehash-ui/src/components/identity/EnsAvatar.tsx index 93963228b5..9f11c97c4f 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 { getEnsMetadataServiceImageUrl } 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 { @@ -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 8abc4ad926..353cfab479 100644 --- a/packages/namehash-ui/src/index.ts +++ b/packages/namehash-ui/src/index.ts @@ -29,4 +29,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; - } -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c2fd3d4a9c..1310e3ab33 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 @@ -1100,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 @@ -1185,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 @@ -1197,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